mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-31 01:09:25 +00:00
feat: implement bucket and object management dialogs, enhance caching, and update theme colors
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { useDashboardData } from './useApi';
|
||||
export { useBuckets } from './useBuckets';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ===========================
|
||||
// Bucket Hooks
|
||||
// ===========================
|
||||
|
||||
export function useBuckets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.buckets.list(),
|
||||
queryFn: () => bucketsApi.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBucket(name: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.buckets.detail(name),
|
||||
queryFn: () => bucketsApi.get(name),
|
||||
enabled: enabled && !!name,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBucket() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, region }: { name: string; region?: string }) =>
|
||||
bucketsApi.create(name, region),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Bucket created successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBucket() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => bucketsApi.delete(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Bucket deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrantBucketPermission() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucketName, accessKeyId, permissions }: {
|
||||
bucketName: string;
|
||||
accessKeyId: string;
|
||||
permissions: { read: boolean; write: boolean; owner: boolean };
|
||||
}) => bucketsApi.grantPermission(bucketName, accessKeyId, permissions),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucketName) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
toast.success('Permissions granted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Object Hooks
|
||||
// ===========================
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.objects.list(bucket, prefix),
|
||||
queryFn: () => objectsApi.list(bucket, prefix),
|
||||
enabled: enabled && !!bucket,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadObject() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, key, file }: { bucket: string; key: string; file: File }) =>
|
||||
objectsApi.upload(bucket, key, file),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('File uploaded successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadMultipleObjects() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, files }: { bucket: string; files: File[] }) =>
|
||||
objectsApi.uploadMultiple(bucket, files),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('Files uploaded successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteObject() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, key }: { bucket: string; key: string }) =>
|
||||
objectsApi.delete(bucket, key),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success('File deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMultipleObjects() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ bucket, keys, prefix }: { bucket: string; keys: string[]; prefix?: string }) =>
|
||||
objectsApi.deleteMultiple(bucket, keys, prefix),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.objects.list(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(variables.bucket) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all });
|
||||
toast.success(`${variables.keys.length} files deleted successfully`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Access Key Hooks
|
||||
// ===========================
|
||||
|
||||
export function useAccessKeys() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accessKeys.list(),
|
||||
queryFn: () => accessApi.listKeys(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccessKey(keyId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.accessKeys.detail(keyId),
|
||||
queryFn: () => accessApi.getKey(keyId),
|
||||
enabled: enabled && !!keyId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, permissions }: { name: string; permissions?: any[] }) =>
|
||||
accessApi.createKey(name, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
toast.success('Access key created successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (keyId: string) => accessApi.deleteKey(keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.all });
|
||||
toast.success('Access key deleted successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, updates }: { keyId: string; updates: any }) =>
|
||||
accessApi.updateKey(keyId, updates),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.detail(variables.keyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.list() });
|
||||
toast.success('Access key updated successfully');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Cluster Hooks
|
||||
// ===========================
|
||||
|
||||
export function useClusterHealth() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.health(),
|
||||
queryFn: () => garageApi.getClusterHealth(),
|
||||
staleTime: 30 * 1000, // Refresh health every 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.status(),
|
||||
queryFn: () => garageApi.getClusterStatus(),
|
||||
staleTime: 60 * 1000, // Refresh status every minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useClusterStatistics() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cluster.statistics(),
|
||||
queryFn: () => garageApi.getClusterStatistics(),
|
||||
staleTime: 60 * 1000, // Refresh statistics every minute
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dashboard Hooks
|
||||
// ===========================
|
||||
|
||||
export function useDashboardMetrics() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.metrics(),
|
||||
queryFn: () => analyticsApi.getMetrics(),
|
||||
staleTime: 2 * 60 * 1000, // Refresh dashboard every 2 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Combined hook for dashboard data
|
||||
export function useDashboardData() {
|
||||
const metrics = useDashboardMetrics();
|
||||
const buckets = useBuckets();
|
||||
const health = useClusterHealth();
|
||||
|
||||
return {
|
||||
metrics,
|
||||
buckets,
|
||||
health,
|
||||
isLoading: metrics.isLoading || buckets.isLoading || health.isLoading,
|
||||
isError: metrics.isError || buckets.isError || health.isError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else if (isNav) {
|
||||
setIsNavigating(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
|
||||
setObjects(response.objects);
|
||||
setIsTruncated(response.isTruncated);
|
||||
setNextContinuationToken(response.nextContinuationToken);
|
||||
setCurrentContinuationToken(continuationToken);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch objects:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsNavigating(false);
|
||||
}
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Detect if this is a path change (navigation) or initial load
|
||||
const isPathChange = previousPath !== currentPath && objects.length > 0;
|
||||
setPreviousPath(currentPath);
|
||||
|
||||
// Use navigation mode if it's a path change, otherwise use normal loading
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
// Use webkitRelativePath if available (for folder uploads), otherwise use file.name
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
await objectsApi.upload(bucketName, key, file);
|
||||
}
|
||||
|
||||
if (hasRelativePaths && folders.size > 0) {
|
||||
const folderNames = Array.from(folders).join(', ');
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
} else {
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteObject = useCallback(async (key: string) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the object from the UI
|
||||
setObjects(prev => prev.filter(obj => obj.key !== key));
|
||||
|
||||
await objectsApi.delete(bucketName, key);
|
||||
toast.success(`Object "${key}" deleted successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete object error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteMultipleObjects = useCallback(async (keys: string[]) => {
|
||||
if (!bucketName || keys.length === 0) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the objects from the UI
|
||||
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
|
||||
toast.success(`Successfully deleted ${keys.length} file${keys.length > 1 ? 's' : ''}`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Bulk delete error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const createDirectory = useCallback(async (dirName: string) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
|
||||
await objectsApi.upload(bucketName, dirKey, new File([], ''));
|
||||
toast.success(`Directory "${dirName}" created successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
return {
|
||||
objects,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
error,
|
||||
isTruncated,
|
||||
nextContinuationToken,
|
||||
currentContinuationToken,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
fetchObjects,
|
||||
uploadFiles,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBuckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchBuckets = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch buckets:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets();
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const createBucket = useCallback(async (name: string, region?: string) => {
|
||||
try {
|
||||
await bucketsApi.create(name, region);
|
||||
toast.success(`Bucket "${name}" created successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const deleteBucket = useCallback(async (name: string) => {
|
||||
try {
|
||||
await bucketsApi.delete(name);
|
||||
toast.success(`Bucket "${name}" deleted successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const grantPermission = useCallback(async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
|
||||
toast.success('Permissions granted successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Grant permission error:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
buckets,
|
||||
isLoading,
|
||||
error,
|
||||
fetchBuckets,
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
grantPermission,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user