mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-09-04 19:25:43 +00:00
add initial project setup
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
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';
|
||||
|
||||
export function AccessControl() {
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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']);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKeys = async () => {
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
setFilteredKeys(data);
|
||||
};
|
||||
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredKeys(filtered);
|
||||
}, [searchQuery, keys]);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName) return;
|
||||
|
||||
const permissions: Permission[] = [
|
||||
{
|
||||
resource: newKeyResource,
|
||||
actions: newKeyActions,
|
||||
effect: 'Allow',
|
||||
},
|
||||
];
|
||||
|
||||
await accessApi.createKey(newKeyName, permissions);
|
||||
setCreateDialogOpen(false);
|
||||
setNewKeyName('');
|
||||
setNewKeyResource('*');
|
||||
setNewKeyActions(['GetObject']);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
};
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
if (!selectedKey) return;
|
||||
|
||||
await accessApi.deleteKey(selectedKey.accessKeyId);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedKey(null);
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
};
|
||||
|
||||
const handleToggleKeyStatus = async (key: AccessKey) => {
|
||||
const newStatus = key.status === 'active' ? 'inactive' : 'active';
|
||||
await accessApi.updateKey(key.accessKeyId, { status: newStatus });
|
||||
|
||||
// Refresh keys list
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
};
|
||||
|
||||
const availableActions = [
|
||||
'GetObject',
|
||||
'PutObject',
|
||||
'DeleteObject',
|
||||
'ListBucket',
|
||||
'GetBucketLocation',
|
||||
'CreateBucket',
|
||||
'DeleteBucket',
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
title="Access Control"
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
<Tabs defaultValue="keys">
|
||||
<TabsList>
|
||||
<TabsTrigger value="keys">API Keys</TabsTrigger>
|
||||
<TabsTrigger value="policies">Bucket Policies</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="keys" className="space-y-6 mt-6">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{keys.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Keys</CardTitle>
|
||||
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{keys.filter((k) => k.status === 'active').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Inactive Keys</CardTitle>
|
||||
<ShieldX className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{keys.filter((k) => k.status === 'inactive').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search keys..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Keys Table */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
<TableHead>Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableCell className="font-medium">{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => navigator.clipboard.writeText(key.accessKeyId)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={key.status === 'active' ? 'default' : 'secondary'}>
|
||||
{key.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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' : ''}
|
||||
</Badge>
|
||||
))}
|
||||
{key.permissions.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{key.permissions.length - 2} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleToggleKeyStatus(key)}>
|
||||
{key.status === 'active' ? (
|
||||
<>
|
||||
<ShieldX className="mr-2 h-4 w-4" />
|
||||
Deactivate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedKey(key);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="policies" className="space-y-6 mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bucket Policies</CardTitle>
|
||||
<CardDescription>
|
||||
Manage resource-based policies for your buckets
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
Bucket policy editor coming soon...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Create Key Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key with specific permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<Input
|
||||
placeholder="My Application Key"
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<div className="grid 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>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName || newKeyActions.length === 0}>
|
||||
Create Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Key Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedKey?.name}"? Applications using this key
|
||||
will lose access immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteKey}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { bucketsApi, objectsApi } from '@/lib/api';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import type { Bucket, S3Object } from '@/types';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Settings,
|
||||
Search,
|
||||
Upload,
|
||||
FolderIcon,
|
||||
FileIcon,
|
||||
Download,
|
||||
ChevronRight,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
RotateCwIcon,
|
||||
FolderPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function Buckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
const [filteredBuckets, setFilteredBuckets] = useState<Bucket[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||
const [newBucketName, setNewBucketName] = useState('');
|
||||
const [showCreatePreview, setShowCreatePreview] = useState(false);
|
||||
|
||||
// Objects state
|
||||
const [viewingBucket, setViewingBucket] = useState<string | null>(null);
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [objectSearchQuery, setObjectSearchQuery] = useState('');
|
||||
const [showUploadZone, setShowUploadZone] = useState(false);
|
||||
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
|
||||
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||
const [newDirName, setNewDirName] = useState('');
|
||||
|
||||
// Main area drag & drop
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles) => {
|
||||
await uploadFiles(acceptedFiles);
|
||||
setShowUploadZone(false);
|
||||
},
|
||||
noClick: true,
|
||||
});
|
||||
|
||||
// File upload handler
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!viewingBucket) return;
|
||||
|
||||
for (const file of files) {
|
||||
const key = currentPath ? `${currentPath}${file.name}` : file.name;
|
||||
await objectsApi.upload(viewingBucket, key, file);
|
||||
}
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBuckets = async () => {
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
setFilteredBuckets(data);
|
||||
};
|
||||
|
||||
fetchBuckets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredBuckets(filtered);
|
||||
}, [searchQuery, buckets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingBucket) {
|
||||
const fetchObjects = async () => {
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
setFilteredObjects(data);
|
||||
};
|
||||
|
||||
fetchObjects();
|
||||
}
|
||||
}, [viewingBucket, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(objectSearchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredObjects(filtered);
|
||||
}, [objectSearchQuery, objects]);
|
||||
|
||||
const handleCreateBucket = async () => {
|
||||
if (!newBucketName) return;
|
||||
|
||||
await bucketsApi.create(newBucketName);
|
||||
setCreateDialogOpen(false);
|
||||
setNewBucketName('');
|
||||
setShowCreatePreview(false);
|
||||
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
};
|
||||
|
||||
const handleDeleteBucket = async () => {
|
||||
if (!selectedBucket) return;
|
||||
|
||||
await bucketsApi.delete(selectedBucket.name);
|
||||
setDeleteBucketDialogOpen(false);
|
||||
setSelectedBucket(null);
|
||||
|
||||
// Refresh bucket list
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
};
|
||||
|
||||
const handleViewBucket = (bucketName: string) => {
|
||||
setViewingBucket(bucketName);
|
||||
setCurrentPath('');
|
||||
setObjectSearchQuery('');
|
||||
};
|
||||
|
||||
const handleBackToBuckets = () => {
|
||||
setViewingBucket(null);
|
||||
setCurrentPath('');
|
||||
setObjectSearchQuery('');
|
||||
};
|
||||
|
||||
const handleNavigateToFolder = (folderKey: string) => {
|
||||
setCurrentPath(folderKey);
|
||||
setObjectSearchQuery('');
|
||||
};
|
||||
|
||||
const handleDeleteObject = async () => {
|
||||
if (!selectedObject || !viewingBucket) return;
|
||||
|
||||
await objectsApi.delete(viewingBucket, selectedObject.key);
|
||||
setDeleteObjectDialogOpen(false);
|
||||
setSelectedObject(null);
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (!viewingBucket) return;
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async () => {
|
||||
if (!newDirName || !viewingBucket) return;
|
||||
|
||||
// Create a directory by uploading an empty object with a trailing slash
|
||||
const dirKey = currentPath ? `${currentPath}${newDirName}/` : `${newDirName}/`;
|
||||
await objectsApi.upload(viewingBucket, dirKey, new File([], ''));
|
||||
|
||||
setCreateDirDialogOpen(false);
|
||||
setNewDirName('');
|
||||
|
||||
// Refresh objects list
|
||||
const data = await objectsApi.list(viewingBucket, currentPath);
|
||||
setObjects(data);
|
||||
};
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the objects view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
title={`Objects in ${viewingBucket}`}
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Back Button */}
|
||||
<Button variant="outline" onClick={handleBackToBuckets}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Buckets
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
{getBreadcrumbs().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={() => setCurrentPath(crumb.path)}
|
||||
className={
|
||||
index === getBreadcrumbs().length - 1
|
||||
? 'font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search objects..."
|
||||
value={objectSearchQuery}
|
||||
onChange={(e) => setObjectSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setShowUploadZone(!showUploadZone)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
Create Directory
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefreshObjects} title="Refresh">
|
||||
<RotateCwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
{/* Upload SVG Icon */}
|
||||
<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>
|
||||
|
||||
{/* Upload Content */}
|
||||
<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 or{' '}
|
||||
<label
|
||||
htmlFor="file-folder-input"
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
select from computer
|
||||
</label>
|
||||
</p>
|
||||
<input
|
||||
id="file-folder-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
setShowUploadZone(false);
|
||||
}
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border rounded-lg transition-colors ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5 border-2'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Last Modified</TableHead>
|
||||
<TableHead>Storage Class</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
{objectSearchQuery
|
||||
? '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>
|
||||
<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={() => handleNavigateToFolder(obj.key)}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? '---' : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>{formatDate(obj.lastModified)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.storageClass && (
|
||||
<Badge variant="secondary">{obj.storageClass}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
{/*make the button not affecting the row height*/}
|
||||
<Button variant="ghost" size="icon" className={"-m-3 top-1 relative"}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedObject(obj);
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Directory Dialog */}
|
||||
<Dialog open={createDirDialogOpen} onOpenChange={setCreateDirDialogOpen}>
|
||||
<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={newDirName}
|
||||
onChange={(e) => setNewDirName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateDirectory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDirDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateDirectory} disabled={!newDirName}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Object Dialog */}
|
||||
<Dialog open={deleteObjectDialogOpen} onOpenChange={setDeleteObjectDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Object</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedObject?.key}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteObjectDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteObject}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default view: show buckets list
|
||||
return (
|
||||
<div>
|
||||
<Header title="Buckets" />
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Buckets Table */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Region</TableHead>
|
||||
<TableHead>Objects</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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={() => handleViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium">{bucket.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
|
||||
<TableCell>{formatDate(bucket.creationDate)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewBucket(bucket.name);
|
||||
}}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
View Objects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => e.stopPropagation()}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedBucket(bucket);
|
||||
setDeleteBucketDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Bucket Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<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={newBucketName}
|
||||
onChange={(e) => setNewBucketName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be unique and follow DNS naming conventions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreatePreview(true)}
|
||||
disabled={!newBucketName}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button onClick={handleCreateBucket} disabled={!newBucketName}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Bucket Preview Dialog */}
|
||||
<Dialog open={showCreatePreview} onOpenChange={setShowCreatePreview}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bucket Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the bucket configuration before creating
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Bucket Name</p>
|
||||
<p className="text-sm font-medium">{newBucketName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<Badge variant="outline">Ready to Create</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreatePreview(false)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleCreateBucket}>
|
||||
Confirm & Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Bucket Dialog */}
|
||||
<Dialog open={deleteBucketDialogOpen} onOpenChange={setDeleteBucketDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Bucket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedBucket?.name}"? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteBucketDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteBucket}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { garageApi, analyticsApi, 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';
|
||||
|
||||
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);
|
||||
|
||||
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 getHealthStatus = (health: ClusterHealth | null) => {
|
||||
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
|
||||
if (
|
||||
health.healthyStorageNodes === health.declaredStorageNodes &&
|
||||
health.healthyPartitions === health.totalPartitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) {
|
||||
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
|
||||
}
|
||||
if (
|
||||
health.healthyStorageNodes > 0 &&
|
||||
health.healthyPartitions > 0
|
||||
) {
|
||||
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
|
||||
};
|
||||
|
||||
const healthStatus = getHealthStatus(clusterHealth);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Top Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics ? formatBytes(metrics.totalSize) : '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {metrics?.bucketCount || 0} buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.objectCount.toLocaleString() || '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Files and folders
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active storage buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requests (24h)</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics
|
||||
? (
|
||||
metrics.requestMetrics.getRequests +
|
||||
metrics.requestMetrics.putRequests +
|
||||
metrics.requestMetrics.deleteRequests +
|
||||
metrics.requestMetrics.listRequests
|
||||
).toLocaleString()
|
||||
: '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GET, PUT, DELETE, LIST
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster Status */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-2xl font-bold ${healthStatus.color}`}>
|
||||
{healthStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.healthyStorageNodes || 0}/{clusterHealth?.declaredStorageNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy storage nodes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.healthyPartitions || 0}/{clusterHealth?.totalPartitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Bucket Usage Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket</CardTitle>
|
||||
<CardDescription>Distribution of storage across buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
<BucketUsageChart data={metrics.usageByBucket} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Request Metrics Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Metrics</CardTitle>
|
||||
<CardDescription>API request distribution (24h)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.requestMetrics ? (
|
||||
<RequestMetricsChart data={metrics.requestMetrics} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Bucket Details Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
|
||||
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.objectCount.toLocaleString()} objects
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(bucket.size)}</span>
|
||||
<span className="text-muted-foreground w-12 text-right">
|
||||
{bucket.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${bucket.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Buckets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Buckets</CardTitle>
|
||||
<CardDescription>Your most recently created buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<div
|
||||
key={bucket.name}
|
||||
className="flex items-center justify-between py-3 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{bucket.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{bucket.size ? formatBytes(bucket.size) : '---'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user