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