add initial project setup

This commit is contained in:
Noooste
2025-11-24 18:16:25 +01:00
parent 998f17c7d9
commit 6b050f1090
57 changed files with 11526 additions and 3 deletions
+409
View File
@@ -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>
);
}
+695
View File
@@ -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>
);
}
+285
View File
@@ -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>
);
}