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
+23 -13
View File
@@ -1,25 +1,35 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider } from '@/components/theme-provider';
import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, useTheme } from '@/components/theme-provider';
import { Layout } from '@/components/layout/layout';
import { Dashboard } from '@/pages/Dashboard';
import { Buckets } from '@/pages/Buckets';
import { AccessControl } from '@/pages/AccessControl';
import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
function ThemedToaster() {
const { theme } = useTheme();
return <Toaster richColors position="bottom-right" theme={theme} />;
}
function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="access" element={<AccessControl />} />
</Route>
</Routes>
</BrowserRouter>
<Toaster richColors position="bottom-right" />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="access" element={<AccessControl />} />
</Route>
</Routes>
</BrowserRouter>
<ThemedToaster />
</ThemeProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,147 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes, formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
interface BucketListViewProps {
buckets: Bucket[];
searchQuery: string;
isLoading?: boolean;
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
}
export function BucketListView({
buckets,
searchQuery,
isLoading = false,
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
}: BucketListViewProps) {
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-4 sm:space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Bucket
</Button>
</div>
{/* Buckets Table */}
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Region</TableHead>
<TableHead className="hidden md:table-cell">Objects</TableHead>
<TableHead>Size</TableHead>
<TableHead className="hidden lg:table-cell">Created</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading buckets...</span>
</div>
</TableCell>
</TableRow>
) : filteredBuckets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No buckets found matching your search' : 'No buckets yet'}
</TableCell>
</TableRow>
) : (
filteredBuckets.map((bucket) => (
<TableRow
key={bucket.name}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onViewBucket(bucket.name)}
>
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
<TableCell className="hidden lg:table-cell">{formatDate(bucket.creationDate)}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="-m-3 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onViewBucket(bucket.name);
}}>
<FolderIcon className="h-4 w-4" />
View Objects
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { accessApi } from '@/lib/api';
import type { AccessKey, Bucket } from '@/types';
import { toast } from 'sonner';
interface BucketSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise<boolean>;
}
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
loadAccessKeys();
resetForm();
}
}, [open, bucket]);
const loadAccessKeys = async () => {
try {
const keys = await accessApi.listKeys();
setAvailableKeys(keys);
} catch (error) {
console.error('Failed to load access keys:', error);
}
};
const resetForm = () => {
setSelectedAccessKey('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
};
const handleAccessKeyChange = (accessKeyId: string) => {
setSelectedAccessKey(accessKeyId);
if (!accessKeyId) {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
return;
}
const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId);
if (selectedKey && bucket) {
const bucketPermission = selectedKey.permissions.find(
perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name
);
if (bucketPermission) {
setPermissionRead(bucketPermission.read);
setPermissionWrite(bucketPermission.write);
setPermissionOwner(bucketPermission.owner);
} else {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
}
}
};
const handleGrantPermission = async () => {
if (!bucket || !selectedAccessKey) {
toast.error('Please select an access key');
return;
}
if (!permissionRead && !permissionWrite && !permissionOwner) {
toast.error('Please select at least one permission');
return;
}
const success = await onGrantPermission(bucket.name, selectedAccessKey, {
read: permissionRead,
write: permissionWrite,
owner: permissionOwner,
});
if (success) {
resetForm();
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
<DialogDescription>
Grant access key permissions for this bucket
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Select Access Key</label>
<Select
value={selectedAccessKey}
onChange={(value) => handleAccessKeyChange(value)}
>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((key) => (
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
{key.name} ({key.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
</p>
</div>
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-start space-x-3">
<Checkbox
id="permission-read"
checked={permissionRead}
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-read"
className="text-sm font-medium leading-none cursor-pointer"
>
Read
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-write"
checked={permissionWrite}
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-write"
className="text-sm font-medium leading-none cursor-pointer"
>
Write
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-owner"
checked={permissionOwner}
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-owner"
className="text-sm font-medium leading-none cursor-pointer"
>
Owner
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
Grant Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBucket: (name: string) => Promise<boolean>;
}
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
return;
}
const success = await onCreateBucket(bucketName);
if (success) {
setBucketName('');
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate();
}
}}
/>
<p className="text-xs text-muted-foreground">
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
onClick={handleCreate}
disabled={!bucketName}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentPath: string;
onCreateDirectory: (name: string) => Promise<boolean>;
}
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
return;
}
const success = await onCreateDirectory(dirName);
if (success) {
setDirName('');
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { Bucket } from '@/types';
interface DeleteBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onDeleteBucket: (name: string) => Promise<boolean>;
}
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
const handleDelete = async () => {
if (!bucket) return;
const success = await onDeleteBucket(bucket.name);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
object: S3Object | null;
onDeleteObject: (key: string) => Promise<boolean>;
}
export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }: DeleteObjectDialogProps) {
const handleDelete = async () => {
if (!object) return;
const success = await onDeleteObject(object.key);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,386 @@
import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Header } from '@/components/layout/header';
import { ObjectsTable } from './ObjectsTable';
import { CreateDirectoryDialog } from './CreateDirectoryDialog';
import { DeleteObjectDialog } from './DeleteObjectDialog';
import { ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload } from 'lucide-react';
import { getBreadcrumbs } from '@/lib/file-utils';
import type { S3Object } from '@/types';
interface ObjectBrowserViewProps {
bucketName: string;
objects: S3Object[];
currentPath: string;
searchQuery: string;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onSearchChange: (query: string) => void;
onNavigateToFolder: (path: string) => void;
onBackToBuckets: () => void;
onUploadFiles: (files: File[]) => Promise<boolean>;
onDeleteObject: (key: string) => Promise<boolean>;
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
onCreateDirectory: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
isRefreshing: boolean;
isNavigating: boolean;
initialPageToken?: string;
initialItemsPerPage?: number;
}
export function ObjectBrowserView({
bucketName,
objects,
currentPath,
searchQuery,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onSearchChange,
onNavigateToFolder,
onBackToBuckets,
onUploadFiles,
onDeleteObject,
onDeleteMultipleObjects,
onCreateDirectory,
onRefresh,
onPageChange,
onItemsPerPageChange,
isRefreshing,
isNavigating,
initialPageToken,
initialItemsPerPage,
}: ObjectBrowserViewProps) {
const [showUploadZone, setShowUploadZone] = useState(false);
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, fileRejections, event) => {
// Get files with their full paths from DataTransferItems API
const filesWithPaths: File[] = [];
if (event.dataTransfer?.items) {
// Use DataTransferItemList API to preserve folder structure
const items = Array.from(event.dataTransfer.items);
await Promise.all(items.map(async (item) => {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.();
if (entry) {
await traverseFileTree(entry, '', filesWithPaths);
}
}
}));
} else {
// Fallback to standard files
filesWithPaths.push(...acceptedFiles);
}
await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles);
setShowUploadZone(false);
},
noClick: true,
});
// Helper function to traverse file/directory tree
const traverseFileTree = async (item: any, path: string, files: File[]): Promise<void> => {
return new Promise((resolve) => {
if (item.isFile) {
item.file((file: File) => {
// Add the relative path to the file object
const fullPath = path + file.name;
Object.defineProperty(file, 'webkitRelativePath', {
value: fullPath,
writable: false
});
files.push(file);
resolve();
});
} else if (item.isDirectory) {
const dirReader = item.createReader();
dirReader.readEntries(async (entries: any[]) => {
for (const entry of entries) {
await traverseFileTree(entry, path + item.name + '/', files);
}
resolve();
});
} else {
resolve();
}
});
};
const handleToggleFileSelection = (key: string) => {
const newSelected = new Set(selectedFileKeys);
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
setSelectedFileKeys(newSelected);
};
const handleSelectAllFiles = () => {
const fileKeys = objects
.filter(obj => !obj.isFolder)
.map(obj => obj.key);
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
setSelectedFileKeys(new Set());
} else {
setSelectedFileKeys(new Set(fileKeys));
}
};
const handleBulkDeleteFiles = async () => {
if (selectedFileKeys.size === 0) return;
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
const success = await onDeleteObject(key);
if (success) {
setDeleteObjectDialogOpen(false);
setSelectedObject(null);
}
return success;
};
const uploadFiles = async (files: File[]) => {
await onUploadFiles(files);
setShowUploadZone(false);
};
return (
<div>
<Header title={`Objects in ${bucketName}`} />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Back Button */}
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back to Buckets</span>
<span className="sm:hidden">Back</span>
</Button>
{/* Breadcrumb Navigation */}
<div className="flex items-center gap-2 text-xs sm:text-sm overflow-x-auto">
<Home className="h-4 w-4 text-muted-foreground" />
{getBreadcrumbs(currentPath).map((crumb, index) => (
<div key={index} className="flex items-center gap-2">
{index > 0 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<button
onClick={() => onNavigateToFolder(crumb.path)}
className={
index === getBreadcrumbs(currentPath).length - 1
? 'font-medium'
: 'text-muted-foreground hover:text-foreground'
}
>
{crumb.label}
</button>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search objects..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedFileKeys.size > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
>
<Trash className="h-4 w-4" />
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
</Button>
)}
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Upload Zone */}
{showUploadZone && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
<div className="flex-shrink-0 flex items-center justify-center">
<div className="w-20 h-20 bg-primary/10 rounded-lg flex items-center justify-center">
<svg
className="w-12 h-12 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
</div>
<div className="flex-1 space-y-3">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
>
<input {...getInputProps()} />
<p className="text-sm">
Drag and drop files/folders or{' '}
<label
htmlFor="file-input"
className="font-medium text-primary hover:underline cursor-pointer"
>
select files
</label>
{' / '}
<label
htmlFor="folder-input"
className="font-medium text-primary hover:underline cursor-pointer"
>
select folder
</label>
</p>
<input
id="file-input"
type="file"
multiple
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
uploadFiles(files);
e.target.value = '';
}
}}
style={{ display: 'none' }}
/>
<input
id="folder-input"
type="file"
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as any)}
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
uploadFiles(files);
e.target.value = '';
}
}}
style={{ display: 'none' }}
/>
</div>
</div>
</div>
</div>
)}
{/* Objects Table with Drag & Drop */}
<div
{...getRootProps()}
className={`relative border rounded-lg transition-all duration-200 overflow-visible ${
isDragActive
? 'border-primary bg-primary/5 border-2 shadow-lg'
: 'border-border'
}`}
>
<input {...getInputProps()} />
{/* Drag & Drop Overlay */}
{isDragActive && (
<div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-sm rounded-lg flex items-center justify-center pointer-events-none">
<div className="bg-background/95 border-2 border-primary border-dashed rounded-lg p-8 shadow-xl">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<Upload className="h-16 w-16 text-primary animate-bounce" />
<div className="absolute inset-0 h-16 w-16 text-primary opacity-30 animate-ping">
<Upload className="h-16 w-16" />
</div>
</div>
<div className="text-center space-y-2">
<p className="text-lg font-semibold text-primary">Drop files here to upload</p>
<p className="text-sm text-muted-foreground">Files will be uploaded to {currentPath || 'root'}</p>
</div>
</div>
</div>
</div>
)}
<ObjectsTable
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
selectedFileKeys={selectedFileKeys}
isDragActive={isDragActive}
isLoading={isLoading && !isRefreshing && !isNavigating}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onNavigateToFolder={onNavigateToFolder}
onDeleteObject={(obj) => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
}}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onPageChange={onPageChange}
onItemsPerPageChange={onItemsPerPageChange}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
</div>
</div>
{/* Create Directory Dialog */}
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
{/* Delete Object Dialog */}
<DeleteObjectDialog
open={deleteObjectDialogOpen}
onOpenChange={setDeleteObjectDialogOpen}
object={selectedObject}
onDeleteObject={handleDeleteObject}
/>
</div>
);
}
@@ -0,0 +1,424 @@
import {useEffect, useState} from 'react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
import {Select, SelectOption} from '@/components/ui/select';
import {formatBytes} from '@/lib/utils';
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
import type {S3Object} from '@/types';
interface ObjectsTableProps {
objects: S3Object[];
currentPath: string;
searchQuery: string;
selectedFileKeys: Set<string>;
isDragActive: boolean;
isLoading?: boolean;
isTruncated?: boolean;
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
onDeleteObject: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
initialPageToken?: string;
initialItemsPerPage?: number;
}
type SortColumn = 'name' | 'size' | 'modified';
type SortDirection = 'asc' | 'desc';
export function ObjectsTable({
objects,
currentPath,
searchQuery,
selectedFileKeys,
isDragActive,
isLoading = false,
isTruncated = false,
nextContinuationToken,
itemsPerPage,
onNavigateToFolder,
onDeleteObject,
onToggleFileSelection,
onSelectAllFiles,
onPageChange,
onItemsPerPageChange,
initialPageToken,
initialItemsPerPage,
}: ObjectsTableProps) {
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const [initialized, setInitialized] = useState(false);
// Initialize from URL params on first load
useEffect(() => {
if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) {
onItemsPerPageChange(initialItemsPerPage);
setInitialized(true);
}
if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) {
// If we have an initial page token, trigger page change
onPageChange(initialPageToken);
setInitialized(true);
}
if (!initialized && !initialPageToken && !initialItemsPerPage) {
setInitialized(true);
}
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
const sortObjects = (objList: S3Object[]): S3Object[] => {
const sorted = [...objList].sort((a, b) => {
// Always put folders before files
const aIsFolder = a.isFolder ? 1 : 0;
const bIsFolder = b.isFolder ? 1 : 0;
if (aIsFolder !== bIsFolder) {
return bIsFolder - aIsFolder;
}
let compareValue = 0;
switch (sortColumn) {
case 'name': {
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
compareValue = aName.localeCompare(bName);
break;
}
case 'size':
compareValue = a.size - b.size;
break;
case 'modified': {
const aDate = new Date(a.lastModified).getTime();
const bDate = new Date(b.lastModified).getTime();
compareValue = aDate - bDate;
break;
}
}
return sortDirection === 'asc' ? compareValue : -compareValue;
});
return sorted;
};
useEffect(() => {
const filtered = objects.filter((obj) =>
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
);
const sorted = sortObjects(filtered);
setFilteredObjects(sorted);
// Reset pagination when path/search changes
setPageTokens([undefined]);
setCurrentPageIndex(0);
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
// Update page tokens when we get a new next token
useEffect(() => {
if (nextContinuationToken && isTruncated) {
setPageTokens(prev => {
const newTokens = [...prev];
// Only add the token if we don't have it yet
const nextIndex = currentPageIndex + 1;
if (nextIndex >= newTokens.length) {
newTokens[nextIndex] = nextContinuationToken;
}
return newTokens;
});
}
}, [nextContinuationToken, isTruncated, currentPageIndex]);
const hasPrevious = currentPageIndex > 0;
const hasNext = isTruncated;
const handleNextPage = () => {
if (hasNext && nextContinuationToken) {
const nextIndex = currentPageIndex + 1;
setCurrentPageIndex(nextIndex);
onPageChange(nextContinuationToken);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handlePreviousPage = () => {
if (hasPrevious) {
const prevIndex = currentPageIndex - 1;
setCurrentPageIndex(prevIndex);
const previousToken = pageTokens[prevIndex];
onPageChange(previousToken);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleItemsPerPageChange = (value: string) => {
onItemsPerPageChange(Number(value));
setPageTokens([undefined]); // Reset to first page
setCurrentPageIndex(0);
};
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
return (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('name')}
>
Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead className="hidden sm:table-cell">Type</TableHead>
<TableHead className="hidden md:table-cell">Storage Class</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('size')}
>
Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('modified')}
>
Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading objects...</span>
</div>
</TableCell>
</TableRow>
) : filteredObjects.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
{searchQuery
? 'No objects found matching your search'
: isDragActive
? 'Drop files or folders here'
: 'No objects in this location'}
</TableCell>
</TableRow>
) : (
filteredObjects.map((obj) => (
<TableRow key={obj.key}>
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
<FolderIcon className="h-4 w-4 text-muted-foreground" />
) : (
<FileIcon className="h-4 w-4 text-muted-foreground" />
)}
{obj.isFolder ? (
<button
onClick={() => onNavigateToFolder(obj.key)}
className="font-medium cursor-pointer underline hover:text-primary"
>
{obj.key.replace(currentPath, '').replace('/', '')}
</button>
) : (
<span className="font-medium">
{obj.key.replace(currentPath, '')}
</span>
)}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell">
{obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))}
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
<TableCell>
{obj.lastModified ?
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})} CET
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 min-w-max">
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
<span className="text-sm text-white">
{new Date(obj.lastModified).toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: 'UTC',
})} UTC
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
<span className="text-sm text-white">
{formatRelativeTime(new Date(obj.lastModified))}
</span>
</div>
<div className="flex gap-3 items-center">
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
<span className="text-sm text-white font-mono">
{new Date(obj.lastModified).toISOString()}
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>: null}
</TableCell>
<TableCell>
{!obj.isFolder && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Download className="h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{(filteredObjects.length > 0 || hasPrevious) && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t bg-background">
{/* Items per page selector */}
<div className="flex items-center gap-2 text-sm relative z-10">
<span className="text-muted-foreground">Items per page:</span>
<Select value={itemsPerPage.toString()} onChange={handleItemsPerPageChange}>
<SelectOption value="10">10</SelectOption>
<SelectOption value="25">25</SelectOption>
<SelectOption value="50">50</SelectOption>
<SelectOption value="100">100</SelectOption>
<SelectOption value="200">200</SelectOption>
</Select>
</div>
{/* Pagination info and controls */}
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Page {currentPageIndex + 1} Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
className="h-8"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
className="h-8"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
)}
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { BucketListView } from './BucketListView';
export { ObjectBrowserView } from './ObjectBrowserView';
export { ObjectsTable } from './ObjectsTable';
export { CreateBucketDialog } from './CreateBucketDialog';
export { DeleteBucketDialog } from './DeleteBucketDialog';
export { BucketSettingsDialog } from './BucketSettingsDialog';
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
export { DeleteObjectDialog } from './DeleteObjectDialog';
+1 -1
View File
@@ -8,7 +8,7 @@ interface HeaderProps {
export function Header({ title }: HeaderProps) {
return (
<header className="sticky top-0 z-40 border-b" style={{ backgroundColor: 'hsl(var(--background))' }}>
<header className="sticky top-0 z-40 border-b" style={{ backgroundColor: 'var(--background)' }}>
<div className="flex h-16 items-center gap-2 sm:gap-4 px-4 sm:px-6 md:pl-6">
<div className="flex-1 min-w-0 md:ml-0 ml-12">
<h1 className="text-lg sm:text-xl md:text-2xl font-semibold truncate">{title}</h1>
+6 -21
View File
@@ -1,12 +1,6 @@
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Database,
Key,
BarChart3,
Settings,
} from 'lucide-react';
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard,} from 'lucide-react';
interface NavItem {
title: string;
@@ -30,16 +24,6 @@ const navItems: NavItem[] = [
href: '/access',
icon: Key,
},
{
title: 'Analytics',
href: '/analytics',
icon: BarChart3,
},
{
title: 'Settings',
href: '/settings',
icon: Settings,
},
];
interface SidebarProps {
@@ -57,7 +41,7 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
style={{ backgroundColor: 'hsl(var(--background))' }}
style={{ backgroundColor: 'var(--background)' }}
>
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
@@ -76,9 +60,10 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
? 'bg-primary shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
>
<Icon className="h-5 w-5" />
{item.title}
+1 -1
View File
@@ -82,7 +82,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
<div
ref={ref}
style={{ backgroundColor: 'hsl(var(--background))' }}
style={{ backgroundColor: 'var(--background)' }}
className={cn(
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
+48 -11
View File
@@ -1,5 +1,6 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import {createPortal} from 'react-dom';
import {cn} from '@/lib/utils';
interface DropdownMenuContextValue {
open: boolean;
@@ -61,6 +62,42 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
({ className, children, align = 'start', ...props }) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
const contentRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ top: 0, left: 0 });
// Calculate position based on trigger element
React.useEffect(() => {
const updatePosition = () => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
let left = rect.left + scrollX;
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
// Adjust horizontal alignment
if (align === 'end') {
left = rect.right + scrollX - 224; // 224px = w-56
} else if (align === 'center') {
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
}
setPosition({ top, left });
}
};
updatePosition();
if (open) {
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
}
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [open, align, triggerRef]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -83,19 +120,17 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
if (!open) return null;
const alignmentClasses = {
start: 'left-0',
end: 'right-0',
center: 'left-1/2 -translate-x-1/2',
};
return (
const content = (
<div
ref={contentRef}
style={{ backgroundColor: 'hsl(var(--popover))' }}
style={{
backgroundColor: 'var(--popover)',
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
}}
className={cn(
'absolute z-50 mt-2 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
alignmentClasses[align],
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
className
)}
{...props}
@@ -103,6 +138,8 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
<div className="py-1">{children}</div>
</div>
);
return createPortal(content, document.body);
}
);
DropdownMenuContent.displayName = 'DropdownMenuContent';
+164
View File
@@ -0,0 +1,164 @@
import * as React from 'react';
import {cn} from '@/lib/utils';
import {ChevronDown, Check} from 'lucide-react';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
value?: string;
onChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
disabled?: boolean;
placeholder?: string;
}
const SelectContext = React.createContext<{
value?: string;
onChange?: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
const useSelectContext = () => {
const context = React.useContext(SelectContext);
if (!context) {
throw new Error('Select components must be used within a Select');
}
return context;
};
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(value);
const containerRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const displayValue = React.useMemo(() => {
const currentValue = value ?? internalValue;
if (!currentValue) return placeholder;
// Extract label from children
const options = React.Children.toArray(children);
const selectedOption = options.find((child) => {
if (React.isValidElement<SelectOptionProps>(child) && child.type === SelectOption) {
return child.props.value === currentValue;
}
return false;
});
if (React.isValidElement<SelectOptionProps>(selectedOption)) {
return selectedOption.props.children;
}
return currentValue;
}, [value, internalValue, children, placeholder]);
React.useEffect(() => {
setInternalValue(value);
}, [value]);
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open]);
const handleChange = (newValue: string) => {
setInternalValue(newValue);
onChange?.(newValue);
setOpen(false);
};
return (
<SelectContext.Provider value={{ value: value ?? internalValue, onChange: handleChange, open, setOpen }}>
<div ref={containerRef} className="relative">
<button
ref={buttonRef}
type="button"
className={cn(
'w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground',
'flex items-center justify-between',
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
!internalValue && !value && 'text-muted-foreground',
className
)}
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
{...props}
>
<span className="truncate">{displayValue}</span>
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
</button>
{open && (
<div
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
style={{ backgroundColor: 'var(--popover)' }}
>
{children}
</div>
)}
</div>
</SelectContext.Provider>
);
}
);
Select.displayName = 'Select';
export interface SelectOptionProps {
value: string;
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
const SelectOption = React.forwardRef<HTMLDivElement, SelectOptionProps>(
({ className, children, value: optionValue, disabled, ...props }, ref) => {
const { value, onChange } = useSelectContext();
const isSelected = value === optionValue;
return (
<div
ref={ref}
className={cn(
'relative flex items-center w-full px-3 py-2 text-sm cursor-pointer select-none bg-transparent',
'transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50',
className
)}
onClick={() => {
if (!disabled) {
onChange?.(optionValue);
}
}}
{...props}
>
<span className="flex-1">{children}</span>
{isSelected && <Check className="h-4 w-4 ml-2" />}
</div>
);
}
);
SelectOption.displayName = 'SelectOption';
export { Select, SelectOption };
+3
View File
@@ -0,0 +1,3 @@
export { useDashboardData } from './useApi';
export { useBuckets } from './useBuckets';
export { useBucketObjects } from './useBucketObjects';
+253
View File
@@ -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,
};
}
+167
View File
@@ -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,
};
}
+77
View File
@@ -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,
};
}
+45 -44
View File
@@ -2,25 +2,26 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 98%;
--popover-foreground: 240 10% 3.9%;
--primary: 28 100% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 28 100% 58%;
/* Light Theme Colors */
--background: #ffffff;
--foreground: #0a0a0f;
--card: #ffffff;
--card-foreground: #0a0a0f;
--popover: #fafafa;
--popover-foreground: #0a0a0f;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #ff9447;
--radius: 0.5rem;
/* Grafana Chart Colors */
@@ -37,26 +38,26 @@
}
.dark {
/* VS Code Dark Blue Theme */
--background: 222 14% 11%;
--foreground: 220 13% 91%;
--card: 222 13% 16%;
--card-foreground: 220 13% 91%;
--popover: 222 13% 18%;
--popover-foreground: 220 13% 91%;
--primary: 28 100% 58%;
--primary-foreground: 0 0% 100%;
--secondary: 222 13% 23%;
--secondary-foreground: 220 13% 91%;
--muted: 222 13% 30%;
--muted-foreground: 220 9% 64%;
--accent: 28 100% 58%;
--accent-foreground: 0 0% 100%;
--destructive: 15 86% 56%;
--destructive-foreground: 220 13% 91%;
--border: 222 13% 23%;
--input: 222 13% 23%;
--ring: 28 100% 58%;
/* Dark Theme Colors - VS Code Inspired */
--background: #1a1d29;
--foreground: #e8eaed;
--card: #252834;
--card-foreground: #e8eaed;
--popover: #2d3142;
--popover-foreground: #e8eaed;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #3a3f52;
--secondary-foreground: #e8eaed;
--muted: #4a5064;
--muted-foreground: #a0a4b8;
--accent: #3a3f52;
--accent-foreground: #e8eaed;
--destructive: #ef5350;
--destructive-foreground: #e8eaed;
--border: #3a3f52;
--input: #3a3f52;
--ring: #ff9447;
/* Grafana Chart Colors */
--chart-blue: #3eb0ff;
@@ -72,12 +73,12 @@
}
* {
border-color: hsl(var(--border));
border-color: var(--border);
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
background-color: var(--background);
color: var(--foreground);
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
@@ -93,11 +94,11 @@
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.2);
background: color-mix(in srgb, var(--muted-foreground) 20%, transparent);
border-radius: 0.375rem;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.3);
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
}
}
+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;
+100
View File
@@ -0,0 +1,100 @@
/**
* Get the file type based on file extension
*/
export function getFileType(filename: string): string {
if (!filename) return 'Unknown';
const extension = filename.split('.').pop()?.toLowerCase() || '';
if (!extension) return 'File';
const typeMap: Record<string, string> = {
// Images
'png': 'Image',
'jpg': 'Image',
'jpeg': 'Image',
'gif': 'Image',
'svg': 'Image',
'webp': 'Image',
// Documents
'pdf': 'PDF',
'doc': 'Document',
'docx': 'Document',
'xls': 'Spreadsheet',
'xlsx': 'Spreadsheet',
'ppt': 'Presentation',
'pptx': 'Presentation',
'txt': 'Text',
// Archives
'zip': 'Archive',
'rar': 'Archive',
'gz': 'Archive',
'tar': 'Archive',
// Video/Audio
'mp4': 'Video',
'avi': 'Video',
'mov': 'Video',
'mkv': 'Video',
'webm': 'Video',
'mp3': 'Audio',
'wav': 'Audio',
'flac': 'Audio',
// Code
'js': 'JavaScript',
'ts': 'TypeScript',
'tsx': 'TypeScript',
'jsx': 'JavaScript',
'py': 'Python',
'java': 'Java',
'cpp': 'C++',
'c': 'C',
'html': 'HTML',
'css': 'CSS',
'json': 'JSON',
'xml': 'XML',
'sql': 'SQL',
// Data
'csv': 'CSV',
};
return typeMap[extension] || extension.toUpperCase();
}
/**
* Generate breadcrumbs from a file path
*/
export function getBreadcrumbs(currentPath: string): Array<{ label: string; path: string }> {
if (!currentPath) return [{ label: 'Root', path: '' }];
const parts = currentPath.split('/').filter(Boolean);
const breadcrumbs = [{ label: 'Root', path: '' }];
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/') + '/';
breadcrumbs.push({ label: part, path });
});
return breadcrumbs;
}
/**
* Format relative time from a date
*/
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) !== 1 ? 's' : ''} ago`;
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) !== 1 ? 's' : ''} ago`;
}
+43
View File
@@ -0,0 +1,43 @@
import { QueryClient } from '@tanstack/react-query';
// Create a query client with default options
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Cache data for 10 minutes (formerly cacheTime)
retry: 1, // Retry failed requests once
refetchOnWindowFocus: false, // Don't refetch when window regains focus
refetchOnMount: false, // Don't refetch on component mount if data exists
placeholderData: (previousData) => previousData, // Keep previous data while fetching new data
},
},
});
// Query keys for consistent cache management
export const queryKeys = {
buckets: {
all: ['buckets'] as const,
list: () => [...queryKeys.buckets.all, 'list'] as const,
detail: (name: string) => [...queryKeys.buckets.all, 'detail', name] as const,
},
objects: {
all: ['objects'] as const,
list: (bucket: string, prefix?: string) => [...queryKeys.objects.all, 'list', bucket, prefix] as const,
},
accessKeys: {
all: ['accessKeys'] as const,
list: () => [...queryKeys.accessKeys.all, 'list'] as const,
detail: (keyId: string) => [...queryKeys.accessKeys.all, 'detail', keyId] as const,
},
cluster: {
all: ['cluster'] as const,
health: () => [...queryKeys.cluster.all, 'health'] as const,
status: () => [...queryKeys.cluster.all, 'status'] as const,
statistics: () => [...queryKeys.cluster.all, 'statistics'] as const,
},
dashboard: {
all: ['dashboard'] as const,
metrics: () => [...queryKeys.dashboard.all, 'metrics'] as const,
},
};
+615 -109
View File
@@ -1,16 +1,9 @@
import { useEffect, useState } from 'react';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {useEffect, useState} from 'react';
import {Header} from '@/components/layout/header';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Badge} from '@/components/ui/badge';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import {
Dialog,
DialogContent,
@@ -26,40 +19,68 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { accessApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import type { AccessKey, Permission } from '@/types';
import {
Plus,
MoreVertical,
Trash2,
Edit,
Search,
Key,
ShieldCheck,
ShieldX,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Checkbox} from '@/components/ui/checkbox';
import {Select, SelectOption} from '@/components/ui/select';
import {accessApi, bucketsApi} from '@/lib/api';
import {formatDate} from '@/lib/utils';
import type {AccessKey, Bucket, BucketPermission} from '@/types';
import {Copy, Edit, Key, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {toast} from 'sonner';
export function AccessControl() {
const [keys, setKeys] = useState<AccessKey[]>([]);
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState<AccessKey | null>(null);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyResource, setNewKeyResource] = useState('*');
const [newKeyActions, setNewKeyActions] = useState<string[]>(['GetObject']);
// Create key with permissions state
const [createAvailableBuckets, setCreateAvailableBuckets] = useState<Bucket[]>([]);
const [createSelectedBucket, setCreateSelectedBucket] = useState<string>('');
const [createPermissionRead, setCreatePermissionRead] = useState(false);
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
// Edit permissions state
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<AccessKey | null>(null);
const [availableBuckets, setAvailableBuckets] = useState<Bucket[]>([]);
const [selectedBucket, setSelectedBucket] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
// Key settings state (activation/expiration)
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
// const [expirationDate, setExpirationDate] = useState<string>('');
// const [neverExpires, setNeverExpires] = useState(true);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
const [expirationDate, setExpirationDate] = useState<string>('');
const [neverExpires, setNeverExpires] = useState(true);
useEffect(() => {
const fetchKeys = async () => {
const data = await accessApi.listKeys();
setKeys(data);
setFilteredKeys(data);
try {
setIsLoading(true);
const data = await accessApi.listKeys();
setKeys(data);
setFilteredKeys(data);
} catch (error) {
console.error('Failed to fetch keys:', error);
} finally {
setIsLoading(false);
}
};
fetchKeys();
@@ -80,36 +101,61 @@ export function AccessControl() {
return;
}
if (newKeyActions.length === 0) {
toast.error('Please select at least one action');
return;
}
try {
const permissions: Permission[] = [
{
resource: newKeyResource,
actions: newKeyActions,
effect: 'Allow',
},
];
const newKey = await accessApi.createKey(newKeyName);
// If user wants to grant permissions inline
if (createGrantPermissions && createSelectedBucket) {
if (createPermissionRead || createPermissionWrite || createPermissionOwner) {
try {
await bucketsApi.grantPermission(createSelectedBucket, newKey.accessKeyId, {
read: createPermissionRead,
write: createPermissionWrite,
owner: createPermissionOwner,
});
} catch (error) {
console.error('Failed to grant permissions:', error);
// Continue even if permission grant fails - key is already created
}
}
}
await accessApi.createKey(newKeyName, permissions);
setCreateDialogOpen(false);
setNewKeyName('');
setNewKeyResource('*');
setNewKeyActions(['GetObject']);
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
setCreateGrantPermissions(false);
// Refresh keys list
const data = await accessApi.listKeys();
setKeys(data);
toast.success(`API Key "${newKeyName}" created successfully`);
} catch (error) {
toast.error('Failed to create API key. Please try again.');
// Error toast is handled by API interceptor
console.error('Create key error:', error);
}
};
const handleOpenCreateDialog = async () => {
setCreateDialogOpen(true);
setNewKeyName('');
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
setCreateGrantPermissions(false);
// Load available buckets
try {
const buckets = await bucketsApi.list();
setCreateAvailableBuckets(buckets);
} catch (error) {
console.error('Failed to load buckets:', error);
}
};
const handleDeleteKey = async () => {
if (!selectedKey) return;
@@ -124,36 +170,149 @@ export function AccessControl() {
setKeys(data);
toast.success(`API Key "${keyName}" deleted successfully`);
} catch (error) {
toast.error('Failed to delete API key. Please try again.');
// Error toast is handled by API interceptor
console.error('Delete key error:', error);
}
};
const handleToggleKeyStatus = async (key: AccessKey) => {
const newStatus = key.status === 'active' ? 'inactive' : 'active';
const handleOpenSettings = (key: AccessKey) => {
setSettingsKey(key);
setKeyStatus(key.status);
setSettingsDialogOpen(true);
// Set expiration date if it exists
if (key.expiration) {
const expDate = new Date(key.expiration);
// Format as YYYY-MM-DDTHH:mm for datetime-local input
const formattedDate = expDate.toISOString().slice(0, 16);
setExpirationDate(formattedDate);
setNeverExpires(false);
} else {
setExpirationDate('');
setNeverExpires(true);
}
};
const handleSaveKeySettings = async () => {
if (!settingsKey) return;
try {
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
const updates: { status?: string; expiration?: string } = {};
// Add status change
updates.status = keyStatus;
// Add expiration if set and not "never expires"
if (!neverExpires && expirationDate) {
updates.expiration = new Date(expirationDate).toISOString();
} else if (neverExpires) {
// Clear expiration by setting status to active
updates.status = 'active';
}
await accessApi.updateKey(settingsKey.accessKeyId, updates);
// Refresh keys list
const data = await accessApi.listKeys();
setKeys(data);
toast.success(`API Key "${key.name}" ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
setSettingsDialogOpen(false);
toast.success(`Key settings updated successfully`);
} catch (error) {
toast.error('Failed to update API key status. Please try again.');
console.error('Toggle key status error:', error);
// Error toast is handled by API interceptor
console.error('Update key settings error:', error);
}
};
const availableActions = [
'GetObject',
'PutObject',
'DeleteObject',
'ListBucket',
'GetBucketLocation',
'CreateBucket',
'DeleteBucket',
];
const handleOpenEditPermissions = async (key: AccessKey) => {
setEditingKey(key);
setEditPermissionsDialogOpen(true);
setSelectedBucket('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
// Load available buckets
try {
const buckets = await bucketsApi.list();
setAvailableBuckets(buckets);
} catch (error) {
console.error('Failed to load buckets:', error);
}
};
const handleBucketChange = (bucketName: string) => {
setSelectedBucket(bucketName);
if (!bucketName || !editingKey) {
// Reset permissions if no bucket selected
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
return;
}
// Find if this key already has permissions on the selected bucket
const bucketPermission = editingKey.permissions.find(
perm => perm.bucketName === bucketName || perm.bucketId === bucketName
);
if (bucketPermission) {
// Set the checkboxes to reflect current permissions
setPermissionRead(bucketPermission.read);
setPermissionWrite(bucketPermission.write);
setPermissionOwner(bucketPermission.owner);
} else {
// No permissions set yet, reset checkboxes
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
}
};
const handleGrantBucketPermission = async () => {
if (!editingKey || !selectedBucket) {
toast.error('Please select a bucket');
return;
}
if (!permissionRead && !permissionWrite && !permissionOwner) {
toast.error('Please select at least one permission');
return;
}
try {
// Call backend API to grant bucket permissions
await bucketsApi.grantPermission(selectedBucket, editingKey.accessKeyId, {
read: permissionRead,
write: permissionWrite,
owner: permissionOwner,
});
toast.success(`Permissions granted on bucket "${selectedBucket}" successfully`);
setEditPermissionsDialogOpen(false);
setSelectedBucket('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
// Refresh keys list to update permissions
const data = await accessApi.listKeys();
setKeys(data);
} catch (error) {
// Error toast is handled by API interceptor
console.error('Grant permission error:', error);
}
};
// Helper function to format permission flags as a readable string
const formatPermissions = (perm: BucketPermission): string => {
const perms = [];
if (perm.read) perms.push('Read');
if (perm.write) perms.push('Write');
if (perm.owner) perms.push('Owner');
return perms.join(', ') || 'None';
};
return (
<div>
@@ -216,15 +375,16 @@ export function AccessControl() {
className="pl-8"
/>
</div>
<Button onClick={() => setCreateDialogOpen(true)} className="w-full sm:w-auto">
<Button onClick={handleOpenCreateDialog} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Key
</Button>
</div>
{/* Keys Table */}
<div className="border rounded-lg overflow-x-auto">
<Table>
<div className="border rounded-lg overflow-visible">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
@@ -237,7 +397,16 @@ export function AccessControl() {
</TableRow>
</TableHeader>
<TableBody>
{filteredKeys.length === 0 ? (
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading API keys...</span>
</div>
</TableCell>
</TableRow>
) : filteredKeys.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
@@ -278,7 +447,7 @@ export function AccessControl() {
<div className="flex flex-wrap gap-1">
{key.permissions.slice(0, 2).map((perm, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{perm.actions.length} action{perm.actions.length > 1 ? 's' : ''}
{perm.bucketName}: {formatPermissions(perm)}
</Badge>
))}
{key.permissions.length > 2 && (
@@ -286,6 +455,9 @@ export function AccessControl() {
+{key.permissions.length - 2} more
</Badge>
)}
{key.permissions.length === 0 && (
<span className="text-xs text-muted-foreground">No permissions</span>
)}
</div>
</TableCell>
<TableCell>
@@ -296,20 +468,20 @@ export function AccessControl() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
<Edit className="h-4 w-4" />
Edit
Edit Permissions
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggleKeyStatus(key)}>
<DropdownMenuItem onClick={() => handleOpenSettings(key)}>
{key.status === 'active' ? (
<>
<ShieldX className="h-4 w-4" />
Deactivate
Manage Status
</>
) : (
<>
<ShieldCheck className="h-4 w-4" />
Activate
Manage Status
</>
)}
</DropdownMenuItem>
@@ -332,6 +504,7 @@ export function AccessControl() {
)}
</TableBody>
</Table>
</div>
</div>
</TabsContent>
@@ -359,7 +532,7 @@ export function AccessControl() {
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key with specific permissions
Create a new API key with optional bucket permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -370,46 +543,103 @@ export function AccessControl() {
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Resource</label>
<Input
placeholder="bucket-name/* or *"
value={newKeyResource}
onChange={(e) => setNewKeyResource(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Specify which resources this key can access
A friendly name to identify this API key
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Actions</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{availableActions.map((action) => (
<label key={action} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={newKeyActions.includes(action)}
onChange={(e) => {
if (e.target.checked) {
setNewKeyActions([...newKeyActions, action]);
} else {
setNewKeyActions(newKeyActions.filter((a) => a !== action));
}
}}
className="rounded border-gray-300"
/>
{action}
</label>
))}
</div>
{/* Optional: Grant permissions during creation */}
<div className="space-y-3 border-t pt-4">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="grant-permissions-on-create"
checked={createGrantPermissions}
onCheckedChange={(checked) => {
setCreateGrantPermissions(checked as boolean);
if (!checked) {
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
}
}}
/>
<span className="text-sm font-medium">Grant bucket permissions now</span>
</label>
<p className="text-xs text-muted-foreground">
You can also grant permissions later from the Edit Permissions menu
</p>
{createGrantPermissions && (
<div className="space-y-4 pl-6 pt-2">
{/* Bucket Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Bucket</label>
<Select
value={createSelectedBucket}
onChange={(value) => setCreateSelectedBucket(value)}
>
<SelectOption value="">-- Select a bucket --</SelectOption>
{createAvailableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
</div>
{/* Permissions */}
{createSelectedBucket && (
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-2 border rounded-lg p-3">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-read"
checked={createPermissionRead}
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Read</span>
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-write"
checked={createPermissionWrite}
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Write</span>
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-owner"
checked={createPermissionOwner}
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Owner</span>
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
</div>
</label>
</div>
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName || newKeyActions.length === 0}>
<Button onClick={handleCreateKey} disabled={!newKeyName}>
Create Key
</Button>
</DialogFooter>
@@ -436,6 +666,282 @@ export function AccessControl() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Key Settings Dialog */}
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Key Settings - {settingsKey?.name}</DialogTitle>
<DialogDescription>
Manage activation status and expiration date for this API key
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Status */}
<div className="space-y-3">
<label className="text-sm font-medium">Status</label>
<div className="flex gap-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="status"
value="active"
checked={keyStatus === 'active'}
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
className="w-4 h-4"
/>
<span className="text-sm">Active</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="radio"
name="status"
value="inactive"
checked={keyStatus === 'inactive'}
onChange={(e) => setKeyStatus(e.target.value as 'active' | 'inactive')}
className="w-4 h-4"
/>
<span className="text-sm">Inactive</span>
</label>
</div>
<p className="text-xs text-muted-foreground">
Inactive keys cannot be used for authentication
</p>
</div>
{/* Expiration */}
<div className="space-y-3">
<label className="text-sm font-medium">Expiration</label>
<div className="space-y-3">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="never-expires"
checked={neverExpires}
onCheckedChange={(checked) => setNeverExpires(checked as boolean)}
/>
<span className="text-sm">Never expires</span>
</label>
{!neverExpires && (
<div className="space-y-2">
<label className="text-sm font-medium">Expiration Date & Time</label>
<Input
type="datetime-local"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
Key will automatically become inactive after this date
</p>
</div>
)}
</div>
</div>
{/* Current Status Display */}
<div className="border rounded-lg p-4 bg-muted/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Status:</span>
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
{settingsKey?.status}
</Badge>
</div>
{settingsKey?.expiration && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Expiration:</span>
<span className="text-sm text-muted-foreground">
{formatDate(settingsKey.expiration)}
</span>
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveKeySettings}>
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Permissions Dialog */}
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Bucket Permissions - {editingKey?.name}</DialogTitle>
<DialogDescription>
Grant this access key permissions on buckets
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Bucket Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Bucket</label>
<Select
value={selectedBucket}
onChange={(value) => handleBucketChange(value)}
>
<SelectOption value="">-- Select a bucket --</SelectOption>
{availableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Choose which bucket this key should have permissions on. Current permissions will be displayed when selected.
</p>
</div>
{/* Permissions */}
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-start space-x-3">
<Checkbox
id="edit-permission-read"
checked={permissionRead}
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="edit-permission-read"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Read
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="edit-permission-write"
checked={permissionWrite}
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="edit-permission-write"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Write
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="edit-permission-owner"
checked={permissionOwner}
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="edit-permission-owner"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Owner
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
</p>
</div>
</div>
</div>
</div>
{/* Current Permissions Info */}
{selectedBucket && editingKey && (
<div className="space-y-2">
<label className="text-sm font-medium">Current Status</label>
<div className="border rounded-lg p-4 bg-muted/50">
{(() => {
const bucketPermission = editingKey.permissions.find(
perm => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket
);
if (bucketPermission) {
const hasPermissions = bucketPermission.read || bucketPermission.write || bucketPermission.owner;
if (hasPermissions) {
return (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
This key currently has the following permissions on this bucket:
</p>
<div className="flex flex-wrap gap-2">
{bucketPermission.read && (
<Badge variant="secondary">Read</Badge>
)}
{bucketPermission.write && (
<Badge variant="secondary">Write</Badge>
)}
{bucketPermission.owner && (
<Badge variant="secondary">Owner</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Modify the checkboxes above to update permissions
</p>
</div>
);
}
}
return (
<p className="text-sm text-muted-foreground">
This key has no permissions on this bucket yet. Select permissions above to grant access.
</p>
);
})()}
</div>
</div>
)}
{/* Current Bucket Permissions List */}
{editingKey && editingKey.permissions.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">Current Bucket Permissions</label>
<div className="border rounded-lg p-4 max-h-48 overflow-y-auto">
<div className="space-y-2">
{editingKey.permissions.map((perm, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 bg-muted/30 rounded">
<span className="font-medium">{perm.bucketName}</span>
<div className="flex gap-1">
{perm.read && <Badge variant="outline" className="text-xs">R</Badge>}
{perm.write && <Badge variant="outline" className="text-xs">W</Badge>}
{perm.owner && <Badge variant="outline" className="text-xs">O</Badge>}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditPermissionsDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleGrantBucketPermission}
disabled={!selectedBucket}
>
Grant Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff
+21 -24
View File
@@ -1,35 +1,18 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Header } from '@/components/layout/header';
import { garageApi, bucketsApi } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import type { GarageMetrics, Bucket, ClusterHealth } from '@/types';
import { Database, FolderOpen, HardDrive, Activity, Server, Zap, AlertCircle } from 'lucide-react';
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
import { RequestMetricsChart } from '@/components/charts/RequestMetricsChart';
import { useDashboardData } from '@/hooks/useApi';
import type { ClusterHealth } from '@/types';
export function Dashboard() {
const [metrics, setMetrics] = useState<GarageMetrics | null>(null);
const [buckets, setBuckets] = useState<Bucket[]>([]);
const [clusterHealth, setClusterHealth] = useState<ClusterHealth | null>(null);
// const [loading, setLoading] = useState(true);
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
useEffect(() => {
const fetchData = async () => {
// setLoading(true);
const [garageMetrics, bucketsData, health] = await Promise.all([
garageApi.getFullMetrics(),
bucketsApi.list(),
garageApi.getClusterHealth(),
]);
setMetrics(garageMetrics);
setBuckets(bucketsData);
setClusterHealth(health);
// setLoading(false);
};
fetchData();
}, []);
const metrics = metricsQuery.data;
const buckets = bucketsQuery.data || [];
const clusterHealth = healthQuery.data;
const getHealthStatus = (health: ClusterHealth | null) => {
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
@@ -49,7 +32,21 @@ export function Dashboard() {
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
};
const healthStatus = getHealthStatus(clusterHealth);
const healthStatus = getHealthStatus(clusterHealth ?? null);
if (isLoading) {
return (
<div>
<Header title="Dashboard" />
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading dashboard...</p>
</div>
</div>
</div>
);
}
return (
<div>
+19 -1
View File
@@ -37,6 +37,15 @@ export interface S3Object {
isFolder?: boolean;
}
export interface ObjectListResponse {
bucket: string;
objects: S3Object[];
prefixes: string[];
count: number;
isTruncated: boolean;
nextContinuationToken?: string;
}
export interface ObjectMetadata {
key: string;
size: number;
@@ -54,7 +63,16 @@ export interface AccessKey {
createdAt: string;
lastUsed?: string;
status: 'active' | 'inactive';
permissions: Permission[];
permissions: BucketPermission[];
expiration?: string;
}
export interface BucketPermission {
bucketId: string;
bucketName: string;
read: boolean;
write: boolean;
owner: boolean;
}
export interface Permission {