diff --git a/.gitignore b/.gitignore index 76895a5..853bdb7 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ data/ meta/ garage.toml -frontend/docs \ No newline at end of file +docs/ \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10697ba..84686bb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.10", "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", @@ -90,7 +91,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2159,6 +2159,32 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", + "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", + "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -2318,7 +2344,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2329,7 +2354,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2340,7 +2364,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2397,7 +2420,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -2649,7 +2671,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2826,7 +2847,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3332,7 +3352,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4636,7 +4655,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4714,7 +4732,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4724,7 +4741,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4754,7 +4770,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", "integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4770,15 +4785,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4880,8 +4893,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5149,7 +5161,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5207,7 +5218,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5324,7 +5334,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5418,7 +5427,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5474,7 +5482,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index e96d890..cd92ee0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.10", "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9abbbac..d9f8133 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; +} function App() { return ( - - - - }> - } /> - } /> - } /> - - - - - + + + + + }> + } /> + } /> + } /> + + + + + + ); } diff --git a/frontend/src/components/buckets/BucketListView.tsx b/frontend/src/components/buckets/BucketListView.tsx new file mode 100644 index 0000000..c217173 --- /dev/null +++ b/frontend/src/components/buckets/BucketListView.tsx @@ -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 ( +
+ {/* Toolbar */} +
+
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+ +
+ + {/* Buckets Table */} +
+ + + + Name + Region + Objects + Size + Created + + + + + {isLoading ? ( + + +
+ + Loading buckets... +
+
+
+ ) : filteredBuckets.length === 0 ? ( + + + {searchQuery ? 'No buckets found matching your search' : 'No buckets yet'} + + + ) : ( + filteredBuckets.map((bucket) => ( + onViewBucket(bucket.name)} + > + {bucket.name} + + {bucket.region || 'default'} + + {bucket.objectCount?.toLocaleString() || 0} + {bucket.size ? formatBytes(bucket.size) : '0 B'} + {formatDate(bucket.creationDate)} + + + e.stopPropagation()}> + + + + { + e.stopPropagation(); + onViewBucket(bucket.name); + }}> + + View Objects + + { + e.stopPropagation(); + onOpenSettings(bucket); + }}> + + Settings + + + { + e.stopPropagation(); + onDeleteBucket(bucket); + }} + > + + Delete + + + + + + )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/buckets/BucketSettingsDialog.tsx b/frontend/src/components/buckets/BucketSettingsDialog.tsx new file mode 100644 index 0000000..7712313 --- /dev/null +++ b/frontend/src/components/buckets/BucketSettingsDialog.tsx @@ -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; +} + +export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) { + const [availableKeys, setAvailableKeys] = useState([]); + const [selectedAccessKey, setSelectedAccessKey] = useState(''); + 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 ( + + + + Bucket Settings - {bucket?.name} + + Grant access key permissions for this bucket + + +
+
+ + +

+ Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected. +

+
+ +
+ +
+
+ setPermissionRead(checked as boolean)} + /> +
+ +

+ Allows reading objects from the bucket (GetObject, HeadObject, ListObjects) +

+
+
+ +
+ setPermissionWrite(checked as boolean)} + /> +
+ +

+ Allows writing and deleting objects in the bucket (PutObject, DeleteObject) +

+
+
+ +
+ setPermissionOwner(checked as boolean)} + /> +
+ +

+ Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy) +

+
+
+
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/CreateBucketDialog.tsx b/frontend/src/components/buckets/CreateBucketDialog.tsx new file mode 100644 index 0000000..6084cb3 --- /dev/null +++ b/frontend/src/components/buckets/CreateBucketDialog.tsx @@ -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; +} + +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 ( + + + + Create New Bucket + + Create a new storage bucket for your objects + + +
+
+ + setBucketName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +

+ Must be unique and follow DNS naming conventions +

+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/CreateDirectoryDialog.tsx b/frontend/src/components/buckets/CreateDirectoryDialog.tsx new file mode 100644 index 0000000..cc1a61f --- /dev/null +++ b/frontend/src/components/buckets/CreateDirectoryDialog.tsx @@ -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; +} + +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 ( + + + + Create Directory + + Create a new directory in {currentPath || 'the root'} + + +
+
+ + setDirName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/DeleteBucketDialog.tsx b/frontend/src/components/buckets/DeleteBucketDialog.tsx new file mode 100644 index 0000000..af519ad --- /dev/null +++ b/frontend/src/components/buckets/DeleteBucketDialog.tsx @@ -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; +} + +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 ( + + + + Delete Bucket + + Are you sure you want to delete "{bucket?.name}"? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/buckets/DeleteObjectDialog.tsx b/frontend/src/components/buckets/DeleteObjectDialog.tsx new file mode 100644 index 0000000..7fa4179 --- /dev/null +++ b/frontend/src/components/buckets/DeleteObjectDialog.tsx @@ -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; +} + +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 ( + + + + Delete Object + + Are you sure you want to delete "{object?.key}"? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/buckets/ObjectBrowserView.tsx b/frontend/src/components/buckets/ObjectBrowserView.tsx new file mode 100644 index 0000000..fb2a138 --- /dev/null +++ b/frontend/src/components/buckets/ObjectBrowserView.tsx @@ -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; + onDeleteObject: (key: string) => Promise; + onDeleteMultipleObjects: (keys: string[]) => Promise; + onCreateDirectory: (name: string) => Promise; + onRefresh: () => Promise; + 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(null); + const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false); + const [selectedFileKeys, setSelectedFileKeys] = useState>(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 => { + 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 => { + const success = await onDeleteObject(key); + if (success) { + setDeleteObjectDialogOpen(false); + setSelectedObject(null); + } + return success; + }; + + const uploadFiles = async (files: File[]) => { + await onUploadFiles(files); + setShowUploadZone(false); + }; + + return ( +
+
+
+ {/* Back Button */} + + + {/* Breadcrumb Navigation */} +
+ + {getBreadcrumbs(currentPath).map((crumb, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ + {/* Toolbar */} +
+
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+
+ {selectedFileKeys.size > 0 && ( + + )} + + + +
+
+ + {/* Upload Zone */} + {showUploadZone && ( +
+
+
+
+ + + + + +
+
+ +
+
+ +

+ Drag and drop files/folders or{' '} + + {' / '} + +

+ { + if (e.target.files) { + const files = Array.from(e.target.files); + uploadFiles(files); + e.target.value = ''; + } + }} + style={{ display: 'none' }} + /> + { + if (e.target.files) { + const files = Array.from(e.target.files); + uploadFiles(files); + e.target.value = ''; + } + }} + style={{ display: 'none' }} + /> +
+
+
+
+ )} + + {/* Objects Table with Drag & Drop */} +
+ + + {/* Drag & Drop Overlay */} + {isDragActive && ( +
+
+
+
+ +
+ +
+
+
+

Drop files here to upload

+

Files will be uploaded to {currentPath || 'root'}

+
+
+
+
+ )} + + { + setSelectedObject(obj); + setDeleteObjectDialogOpen(true); + }} + onToggleFileSelection={handleToggleFileSelection} + onSelectAllFiles={handleSelectAllFiles} + onPageChange={onPageChange} + onItemsPerPageChange={onItemsPerPageChange} + initialPageToken={initialPageToken} + initialItemsPerPage={initialItemsPerPage} + /> +
+
+ + {/* Create Directory Dialog */} + + + {/* Delete Object Dialog */} + +
+ ); +} diff --git a/frontend/src/components/buckets/ObjectsTable.tsx b/frontend/src/components/buckets/ObjectsTable.tsx new file mode 100644 index 0000000..4d998f1 --- /dev/null +++ b/frontend/src/components/buckets/ObjectsTable.tsx @@ -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; + 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('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [filteredObjects, setFilteredObjects] = useState([]); + // 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 ( + <> +
+ + + + + !obj.isFolder).length > 0 && + selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length + } + onCheckedChange={onSelectAllFiles} + aria-label="Select all files" + /> + + handleSort('name')} + > + Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')} + + Type + Storage Class + handleSort('size')} + > + Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('modified')} + > + Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')} + + + + + + {isLoading ? ( + + +
+ + Loading objects... +
+
+
+ ) : filteredObjects.length === 0 ? ( + + + {searchQuery + ? 'No objects found matching your search' + : isDragActive + ? 'Drop files or folders here' + : 'No objects in this location'} + + + ) : ( + filteredObjects.map((obj) => ( + + + {obj.isFolder ? ( + + ) : ( + onToggleFileSelection(obj.key)} + aria-label={`Select file ${obj.key}`} + /> + )} + + +
+ {obj.isFolder ? ( + + ) : ( + + )} + {obj.isFolder ? ( + + ) : ( + + {obj.key.replace(currentPath, '')} + + )} +
+
+ + {obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))} + + + {obj.storageClass && ( + {obj.storageClass} + )} + + {obj.isFolder ? null : formatBytes(obj.size)} + + {obj.lastModified ? + + + +
+ {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 +
+
+ +
+
+ UTC + + {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 + +
+
+ Relative + + {formatRelativeTime(new Date(obj.lastModified))} + +
+
+ Timestamp + + {new Date(obj.lastModified).toISOString()} + +
+
+
+
+
: null} +
+ + {!obj.isFolder && ( + + + + + + + + Download + + + onDeleteObject(obj)} + > + + Delete + + + + )} + +
+ )) + )} +
+
+
+ + {/* Pagination Controls */} + {(filteredObjects.length > 0 || hasPrevious) && ( +
+ {/* Items per page selector */} +
+ Items per page: + +
+ + {/* Pagination info and controls */} +
+ + Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''} + + +
+ + + +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/buckets/index.ts b/frontend/src/components/buckets/index.ts new file mode 100644 index 0000000..736dfcc --- /dev/null +++ b/frontend/src/components/buckets/index.ts @@ -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'; diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index 9ef1417..5a70feb 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -8,7 +8,7 @@ interface HeaderProps { export function Header({ title }: HeaderProps) { return ( -
+

{title}

diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index d5c35d1..d972cd7 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -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)' }} >
Garage UI Logo @@ -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} > {item.title} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 5d9e018..5ba1182 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -82,7 +82,7 @@ const DialogContent = React.forwardRef
{ const { open, setOpen, triggerRef } = useDropdownMenu(); const contentRef = React.useRef(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{children}
); + + return createPortal(content, document.body); } ); DropdownMenuContent.displayName = 'DropdownMenuContent'; diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..7b1546c --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -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( + ({ 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(null); + const buttonRef = React.useRef(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(child) && child.type === SelectOption) { + return child.props.value === currentValue; + } + return false; + }); + + if (React.isValidElement(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 ( + +
+ + + {open && ( +
+ {children} +
+ )} +
+
+ ); + } +); +Select.displayName = 'Select'; + +export interface SelectOptionProps { + value: string; + children: React.ReactNode; + className?: string; + disabled?: boolean; +} + +const SelectOption = React.forwardRef( + ({ className, children, value: optionValue, disabled, ...props }, ref) => { + const { value, onChange } = useSelectContext(); + const isSelected = value === optionValue; + + return ( +
{ + if (!disabled) { + onChange?.(optionValue); + } + }} + {...props} + > + {children} + {isSelected && } +
+ ); + } +); +SelectOption.displayName = 'SelectOption'; + +export { Select, SelectOption }; + diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..cc7deb4 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,3 @@ +export { useDashboardData } from './useApi'; +export { useBuckets } from './useBuckets'; +export { useBucketObjects } from './useBucketObjects'; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts new file mode 100644 index 0000000..d987ac9 --- /dev/null +++ b/frontend/src/hooks/useApi.ts @@ -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, + }; +} diff --git a/frontend/src/hooks/useBucketObjects.ts b/frontend/src/hooks/useBucketObjects.ts new file mode 100644 index 0000000..b19df14 --- /dev/null +++ b/frontend/src/hooks/useBucketObjects.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isNavigating, setIsNavigating] = useState(false); + const [error, setError] = useState(null); + const [isTruncated, setIsTruncated] = useState(false); + const [nextContinuationToken, setNextContinuationToken] = useState(undefined); + const [itemsPerPage, setItemsPerPage] = useState(25); + const [currentContinuationToken, setCurrentContinuationToken] = useState(undefined); + const [previousPath, setPreviousPath] = useState(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(); + 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, + }; +} diff --git a/frontend/src/hooks/useBuckets.ts b/frontend/src/hooks/useBuckets.ts new file mode 100644 index 0000000..43001a8 --- /dev/null +++ b/frontend/src/hooks/useBuckets.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4e9db6f..d9248b5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 87bc8b0..c468910 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => { - // 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 => { - // const response = await api.get(`/buckets/${_name}`); - // return response.data; - return mockBucketDetails; // Using mock data for now + get: async (name: string): Promise => { + const response = await api.get(`/v1/buckets/${name}`); + return response.data.data; }, create: async (bucketName: string, bucketRegion?: string): Promise => { - // 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 => { - // 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 => { + await api.post(`/v1/buckets/${bucketName}/permissions`, { + accessKeyId, + permissions, + }); }, updateSettings: async (name: string, settings: Partial): Promise => { - // 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 => { - // 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 => { + // 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(); + // 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 => { + const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, { + responseType: 'blob' }); - - // Return unique objects - const seen = new Set(); - 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 => { - // 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 => { - // 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 => { + const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`); + return response.data.data; }, upload: async (bucket: string, key: string, file: File): Promise => { - // 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 => { + 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 => { + 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 => { - // 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 => { - // await api.post(`/buckets/${bucket}/objects/delete`, { keys }); - console.log('Delete multiple objects:', bucket, keys); + deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise => { + const payload = { keys, ...(prefix && { prefix }) }; + await api.post(`/v1/buckets/${bucket}/objects/delete-multiple`, payload); }, - getPresignedUrl: async (_bucket: string, _key: string, _expiresIn?: number): Promise => { - // 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 => { + 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 => { - // 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 => { - // 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 => { + const response = await api.get(`/v1/users/${accessKey}`); + return response.data.data; }, - updateKey: async (keyId: string, updates: Partial): Promise => { - // 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 => { + const response = await api.post('/v1/users', { name, permissions }); + return response.data.data; }, - deleteKey: async (keyId: string): Promise => { - // 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 => { + await api.patch(`/v1/users/${accessKey}`, updates); + }, + + deleteKey: async (accessKey: string): Promise => { + await api.delete(`/v1/users/${accessKey}`); }, }; // Analytics API export const analyticsApi = { getMetrics: async (): Promise => { - // 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 => { - // 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 => { + const response = await api.get('/v1/cluster/status'); + return response.data.data; }, getClusterStatistics: async (): Promise => { - // 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 => { - // 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 => { + 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 => { + const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`); + return response.data.data; }, getFullMetrics: async (): Promise => { @@ -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 => { + 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 => { + 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; diff --git a/frontend/src/lib/file-utils.ts b/frontend/src/lib/file-utils.ts new file mode 100644 index 0000000..7055123 --- /dev/null +++ b/frontend/src/lib/file-utils.ts @@ -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 = { + // 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`; +} diff --git a/frontend/src/lib/query-client.ts b/frontend/src/lib/query-client.ts new file mode 100644 index 0000000..d0d0936 --- /dev/null +++ b/frontend/src/lib/query-client.ts @@ -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, + }, +}; diff --git a/frontend/src/pages/AccessControl.tsx b/frontend/src/pages/AccessControl.tsx index a0bf5ad..2d5171e 100644 --- a/frontend/src/pages/AccessControl.tsx +++ b/frontend/src/pages/AccessControl.tsx @@ -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([]); const [filteredKeys, setFilteredKeys] = useState([]); const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [newKeyName, setNewKeyName] = useState(''); - const [newKeyResource, setNewKeyResource] = useState('*'); - const [newKeyActions, setNewKeyActions] = useState(['GetObject']); + + // Create key with permissions state + const [createAvailableBuckets, setCreateAvailableBuckets] = useState([]); + const [createSelectedBucket, setCreateSelectedBucket] = useState(''); + 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(null); + const [availableBuckets, setAvailableBuckets] = useState([]); + const [selectedBucket, setSelectedBucket] = useState(''); + 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(null); + // const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active'); + // const [expirationDate, setExpirationDate] = useState(''); + // const [neverExpires, setNeverExpires] = useState(true); + + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [settingsKey, setSettingsKey] = useState(null); + const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active'); + const [expirationDate, setExpirationDate] = useState(''); + 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 (
@@ -216,15 +375,16 @@ export function AccessControl() { className="pl-8" />
-
{/* Keys Table */} -
- +
+
+
Name @@ -237,7 +397,16 @@ export function AccessControl() { - {filteredKeys.length === 0 ? ( + {isLoading ? ( + + +
+ + Loading API keys... +
+
+
+ ) : filteredKeys.length === 0 ? ( {searchQuery ? 'No keys found matching your search' : 'No API keys yet'} @@ -278,7 +447,7 @@ export function AccessControl() {
{key.permissions.slice(0, 2).map((perm, idx) => ( - {perm.actions.length} action{perm.actions.length > 1 ? 's' : ''} + {perm.bucketName}: {formatPermissions(perm)} ))} {key.permissions.length > 2 && ( @@ -286,6 +455,9 @@ export function AccessControl() { +{key.permissions.length - 2} more )} + {key.permissions.length === 0 && ( + No permissions + )}
@@ -296,20 +468,20 @@ export function AccessControl() { - + handleOpenEditPermissions(key)}> - Edit + Edit Permissions - handleToggleKeyStatus(key)}> + handleOpenSettings(key)}> {key.status === 'active' ? ( <> - Deactivate + Manage Status ) : ( <> - Activate + Manage Status )} @@ -332,6 +504,7 @@ export function AccessControl() { )}
+
@@ -359,7 +532,7 @@ export function AccessControl() { Create API Key - Create a new API key with specific permissions + Create a new API key with optional bucket permissions
@@ -370,46 +543,103 @@ export function AccessControl() { value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} /> -
-
- - setNewKeyResource(e.target.value)} - />

- Specify which resources this key can access + A friendly name to identify this API key

-
- -
- {availableActions.map((action) => ( - - ))} -
+ + {/* Optional: Grant permissions during creation */} +
+ +

+ You can also grant permissions later from the Edit Permissions menu +

+ + {createGrantPermissions && ( +
+ {/* Bucket Selection */} +
+ + +
+ + {/* Permissions */} + {createSelectedBucket && ( +
+ +
+ + + + + +
+
+ )} +
+ )}
- @@ -436,6 +666,282 @@ export function AccessControl() { + + {/* Key Settings Dialog */} + + + + Key Settings - {settingsKey?.name} + + Manage activation status and expiration date for this API key + + +
+ {/* Status */} +
+ +
+ + +
+

+ Inactive keys cannot be used for authentication +

+
+ + {/* Expiration */} +
+ +
+ + + {!neverExpires && ( +
+ + setExpirationDate(e.target.value)} + className="w-full" + /> +

+ Key will automatically become inactive after this date +

+
+ )} +
+
+ + {/* Current Status Display */} +
+
+
+ Current Status: + + {settingsKey?.status} + +
+ {settingsKey?.expiration && ( +
+ Current Expiration: + + {formatDate(settingsKey.expiration)} + +
+ )} +
+
+
+ + + + +
+
+ + {/* Edit Permissions Dialog */} + + + + Edit Bucket Permissions - {editingKey?.name} + + Grant this access key permissions on buckets + + +
+ {/* Bucket Selection */} +
+ + +

+ Choose which bucket this key should have permissions on. Current permissions will be displayed when selected. +

+
+ + {/* Permissions */} +
+ +
+
+ setPermissionRead(checked as boolean)} + /> +
+ +

+ Allows reading objects from the bucket (GetObject, HeadObject, ListObjects) +

+
+
+ +
+ setPermissionWrite(checked as boolean)} + /> +
+ +

+ Allows writing and deleting objects in the bucket (PutObject, DeleteObject) +

+
+
+ +
+ setPermissionOwner(checked as boolean)} + /> +
+ +

+ Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy) +

+
+
+
+
+ + {/* Current Permissions Info */} + {selectedBucket && editingKey && ( +
+ +
+ {(() => { + 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 ( +
+

+ This key currently has the following permissions on this bucket: +

+
+ {bucketPermission.read && ( + Read + )} + {bucketPermission.write && ( + Write + )} + {bucketPermission.owner && ( + Owner + )} +
+

+ Modify the checkboxes above to update permissions +

+
+ ); + } + } + return ( +

+ This key has no permissions on this bucket yet. Select permissions above to grant access. +

+ ); + })()} +
+
+ )} + + {/* Current Bucket Permissions List */} + {editingKey && editingKey.permissions.length > 0 && ( +
+ +
+
+ {editingKey.permissions.map((perm, idx) => ( +
+ {perm.bucketName} +
+ {perm.read && R} + {perm.write && W} + {perm.owner && O} +
+
+ ))} +
+
+
+ )} +
+ + + + +
+
); } diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx index ce41446..41d7da2 100644 --- a/frontend/src/pages/Buckets.tsx +++ b/frontend/src/pages/Buckets.tsx @@ -1,836 +1,177 @@ -import { useEffect, useState } from 'react'; -import { useDropzone } from 'react-dropzone'; +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; 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, - Trash, -} from 'lucide-react'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; +import { useBuckets } from '@/hooks/useBuckets'; +import { useBucketObjects } from '@/hooks/useBucketObjects'; +import { BucketListView } from '@/components/buckets/BucketListView'; +import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView'; +import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog'; +import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog'; +import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog'; +import type { Bucket } from '@/types'; import { toast } from 'sonner'; export function Buckets() { - const [buckets, setBuckets] = useState([]); - const [filteredBuckets, setFilteredBuckets] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); + + // Bucket state const [searchQuery, setSearchQuery] = useState(''); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false); const [selectedBucket, setSelectedBucket] = useState(null); - const [newBucketName, setNewBucketName] = useState(''); - const [showCreatePreview, setShowCreatePreview] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [settingsBucket, setSettingsBucket] = useState(null); - // Objects state - const [viewingBucket, setViewingBucket] = useState(null); - const [objects, setObjects] = useState([]); - const [filteredObjects, setFilteredObjects] = useState([]); - const [currentPath, setCurrentPath] = useState(''); + // Object browser state - initialize from URL params + const [viewingBucket, setViewingBucket] = useState(searchParams.get('bucket')); + const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') || ''); const [objectSearchQuery, setObjectSearchQuery] = useState(''); - const [showUploadZone, setShowUploadZone] = useState(false); - const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false); - const [selectedObject, setSelectedObject] = useState(null); - const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false); - const [newDirName, setNewDirName] = useState(''); - const [sortColumn, setSortColumn] = useState<'name' | 'size' | 'modified'>('name'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - const [selectedFileKeys, setSelectedFileKeys] = useState>(new Set()); - const [isRefreshing, setIsRefreshing] = useState(false); - - // 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; - - try { - 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); - toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`); - } catch (error) { - toast.error('Failed to upload files. Please try again.'); - console.error('Upload error:', error); - } - }; + const [initialPageToken, setInitialPageToken] = useState( + searchParams.get('page') || undefined + ); + const [initialItemsPerPage, setInitialItemsPerPage] = useState( + parseInt(searchParams.get('limit') || '25', 10) + ); + // Sync URL params with state on mount and when URL changes useEffect(() => { - const fetchBuckets = async () => { - const data = await bucketsApi.list(); - setBuckets(data); - setFilteredBuckets(data); - }; + const bucketParam = searchParams.get('bucket'); + const prefixParam = searchParams.get('prefix') || ''; + const pageParam = searchParams.get('page') || undefined; + const limitParam = parseInt(searchParams.get('limit') || '25', 10); - 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(); + if (bucketParam !== viewingBucket) { + setViewingBucket(bucketParam); } - }, [viewingBucket, currentPath]); - - 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; // Folders first (1 > 0) - } - - // Then sort by selected column - 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(objectSearchQuery.toLowerCase()) - ); - const sorted = sortObjects(filtered); - setFilteredObjects(sorted); - }, [objectSearchQuery, objects, sortColumn, sortDirection, currentPath]); - - const handleCreateBucket = async () => { - if (!newBucketName) { - toast.error('Please enter a bucket name'); - return; + if (prefixParam !== currentPath) { + setCurrentPath(prefixParam); } + setInitialPageToken(pageParam); + setInitialItemsPerPage(limitParam); + }, [searchParams]); - try { - await bucketsApi.create(newBucketName); - setCreateDialogOpen(false); - setNewBucketName(''); - setShowCreatePreview(false); - - // Refresh bucket list - const data = await bucketsApi.list(); - setBuckets(data); - toast.success(`Bucket "${newBucketName}" created successfully`); - } catch (error) { - toast.error('Failed to create bucket. Please try again.'); - console.error('Create bucket error:', error); - } - }; - - const handleDeleteBucket = async () => { - if (!selectedBucket) return; - - try { - await bucketsApi.delete(selectedBucket.name); - const bucketName = selectedBucket.name; - setDeleteBucketDialogOpen(false); - setSelectedBucket(null); - - // Refresh bucket list - const data = await bucketsApi.list(); - setBuckets(data); - toast.success(`Bucket "${bucketName}" deleted successfully`); - } catch (error) { - toast.error('Failed to delete bucket. Please try again.'); - console.error('Delete bucket error:', error); - } - }; + // Custom hooks + const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets(); + const { + objects, + isLoading: objectsLoading, + isRefreshing, + isNavigating, + isTruncated, + nextContinuationToken, + itemsPerPage, + setItemsPerPage, + uploadFiles, + deleteObject, + deleteMultipleObjects, + createDirectory, + fetchObjects + } = useBucketObjects( + viewingBucket, + currentPath + ); const handleViewBucket = (bucketName: string) => { setViewingBucket(bucketName); setCurrentPath(''); setObjectSearchQuery(''); + setSearchParams({ bucket: bucketName }); }; const handleBackToBuckets = () => { setViewingBucket(null); setCurrentPath(''); setObjectSearchQuery(''); + setSearchParams({}); }; - const handleNavigateToFolder = (folderKey: string) => { - setCurrentPath(folderKey); - setObjectSearchQuery(''); - }; - - const handleDeleteObject = async () => { - if (!selectedObject || !viewingBucket) return; - - try { - await objectsApi.delete(viewingBucket, selectedObject.key); - const objectName = selectedObject.key; - setDeleteObjectDialogOpen(false); - setSelectedObject(null); - - // Refresh objects list - const data = await objectsApi.list(viewingBucket, currentPath); - setObjects(data); - toast.success(`Object "${objectName}" deleted successfully`); - } catch (error) { - toast.error('Failed to delete object. Please try again.'); - console.error('Delete object error:', error); + const handleNavigateToFolder = (path: string) => { + setCurrentPath(path); + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (path) { + params.prefix = path; + } + // Reset pagination when navigating to a new folder + setSearchParams(params); } }; + const handlePageChange = (token?: string) => { + fetchObjects(token); + // Update URL with page token + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (currentPath) { + params.prefix = currentPath; + } + if (token) { + params.page = token; + } + if (itemsPerPage !== 25) { + params.limit = itemsPerPage.toString(); + } + setSearchParams(params); + } + }; + + const handleItemsPerPageChange = (count: number) => { + setItemsPerPage(count); + // Update URL with new limit + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (currentPath) { + params.prefix = currentPath; + } + if (count !== 25) { + params.limit = count.toString(); + } + setSearchParams(params); + } + }; + + const handleOpenSettings = (bucket: Bucket) => { + setSettingsBucket(bucket); + setSettingsDialogOpen(true); + }; + const handleRefreshObjects = async () => { - if (!viewingBucket || isRefreshing) return; + if (isRefreshing) return; try { - setIsRefreshing(true); - const data = await objectsApi.list(viewingBucket, currentPath); - setObjects(data); + await fetchObjects(undefined, true); toast.success('Objects refreshed successfully'); } catch (error) { - toast.error('Failed to refresh objects. Please try again.'); console.error('Refresh error:', error); - } finally { - setTimeout(() => setIsRefreshing(false), 600); } }; - 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 = filteredObjects - .filter(obj => !obj.isFolder) - .map(obj => obj.key); - - if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) { - // Deselect all - setSelectedFileKeys(new Set()); - } else { - // Select all files - setSelectedFileKeys(new Set(fileKeys)); - } - }; - - const handleBulkDeleteFiles = async () => { - if (!viewingBucket || selectedFileKeys.size === 0) return; - - try { - const count = selectedFileKeys.size; - for (const key of selectedFileKeys) { - await objectsApi.delete(viewingBucket, key); - } - - setSelectedFileKeys(new Set()); - - // Refresh objects list - const data = await objectsApi.list(viewingBucket, currentPath); - setObjects(data); - toast.success(`Successfully deleted ${count} file${count > 1 ? 's' : ''}`); - } catch (error) { - toast.error('Failed to delete files. Please try again.'); - console.error('Bulk delete error:', error); - } - }; - - const handleCreateDirectory = async () => { - if (!newDirName || !viewingBucket) { - toast.error('Please enter a directory name'); - return; - } - - try { - // 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); - toast.success(`Directory "${newDirName}" created successfully`); - } catch (error) { - toast.error('Failed to create directory. Please try again.'); - console.error('Create directory error:', error); - } - }; - - 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; - }; - - const getFileType = (filename: string): string => { - if (!filename) return 'Unknown'; - - const extension = filename.split('.').pop()?.toLowerCase() || ''; - if (!extension) return 'File'; - - const typeMap: Record = { - // 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(); - }; - - // If viewing a bucket's objects, show the objects view + // If viewing a bucket's objects, show the object browser view if (viewingBucket) { return ( -
-
-
- {/* Back Button */} - - - {/* Breadcrumb Navigation */} -
- - {getBreadcrumbs().map((crumb, index) => ( -
- {index > 0 && } - -
- ))} -
- - {/* Toolbar */} -
-
- - setObjectSearchQuery(e.target.value)} - className="pl-8" - /> -
-
- {selectedFileKeys.size > 0 && ( - - )} - - - -
-
- - {/* Upload Zone */} - {showUploadZone && ( -
-
- {/* Upload SVG Icon */} -
-
- - - - - -
-
- - {/* Upload Content */} -
-
- -

- Drag and drop or{' '} - -

- { - if (e.target.files) { - const files = Array.from(e.target.files); - uploadFiles(files); - setShowUploadZone(false); - } - }} - style={{ display: 'none' }} - /> -
-
-
-
- )} - - {/* Objects Table with Drag & Drop */} -
- - - - - - !obj.isFolder).length > 0 && - selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length - } - onCheckedChange={handleSelectAllFiles} - aria-label="Select all files" - /> - - { - if (sortColumn === 'name') { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn('name'); - setSortDirection('asc'); - } - }}> - Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')} - - Type - Storage Class - { - if (sortColumn === 'size') { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn('size'); - setSortDirection('asc'); - } - }}> - Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')} - - { - if (sortColumn === 'modified') { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn('modified'); - setSortDirection('asc'); - } - }}> - Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')} - - - - - - {filteredObjects.length === 0 ? ( - - - {objectSearchQuery - ? 'No objects found matching your search' - : isDragActive - ? 'Drop files or folders here' - : 'No objects in this location'} - - - ) : ( - filteredObjects.map((obj) => ( - - - {obj.isFolder ? ( - - ) : ( - handleToggleFileSelection(obj.key)} - aria-label={`Select file ${obj.key}`} - /> - )} - - -
- {obj.isFolder ? ( - - ) : ( - - )} - {obj.isFolder ? ( - - ) : ( - - {obj.key.replace(currentPath, '')} - - )} -
-
- - {obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))} - - - {obj.storageClass && ( - {obj.storageClass} - )} - - {obj.isFolder ? null : formatBytes(obj.size)} - - - - -
- {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 -
-
- -
-
- UTC - - {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 - -
-
- Relative - - {(() => { - const now = new Date(); - const date = new Date(obj.lastModified); - 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`; - })()} - -
-
- Timestamp - - {new Date(obj.lastModified).toISOString()} - -
-
-
-
-
-
- - {!obj.isFolder && ( - - - {/*make the button not affecting the row height*/} - - - - - - Download - - - { - setSelectedObject(obj); - setDeleteObjectDialogOpen(true); - }} - > - - Delete - - - - )} - -
- )) - )} -
-
-
-
- - {/* Create Directory Dialog */} - - - - Create Directory - - Create a new directory in {currentPath || 'the root'} - - -
-
- - setNewDirName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleCreateDirectory(); - } - }} - /> -
-
- - - - -
-
- - {/* Delete Object Dialog */} - - - - Delete Object - - Are you sure you want to delete "{selectedObject?.key}"? This action cannot be undone. - - - - - - - - -
+ ); } @@ -838,191 +179,42 @@ export function Buckets() { return (
-
- {/* Toolbar */} -
-
- - setSearchQuery(e.target.value)} - className="pl-8" - /> -
- -
- - {/* Buckets Table */} -
- - - - Name - Region - Objects - Size - Created - - - - - {filteredBuckets.length === 0 ? ( - - - {searchQuery ? 'No buckets found matching your search' : 'No buckets yet'} - - - ) : ( - filteredBuckets.map((bucket) => ( - handleViewBucket(bucket.name)} - > - {bucket.name} - - {bucket.region || 'default'} - - {bucket.objectCount?.toLocaleString() || 0} - {bucket.size ? formatBytes(bucket.size) : '0 B'} - {formatDate(bucket.creationDate)} - - - e.stopPropagation()}> - - - - { - e.stopPropagation(); - handleViewBucket(bucket.name); - }}> - - View Objects - - e.stopPropagation()}> - - Settings - - - { - e.stopPropagation(); - setSelectedBucket(bucket); - setDeleteBucketDialogOpen(true); - }} - > - - Delete - - - - - - )) - )} - -
-
+
+ setCreateDialogOpen(true)} + onDeleteBucket={(bucket) => { + setSelectedBucket(bucket); + setDeleteBucketDialogOpen(true); + }} + />
- {/* Create Bucket Dialog */} - - - - Create New Bucket - - Create a new storage bucket for your objects - - -
-
- - setNewBucketName(e.target.value)} - /> -

- Must be unique and follow DNS naming conventions -

-
-
- - - - -
-
+ {/* Dialogs */} + - {/* Create Bucket Preview Dialog */} - - - - Bucket Preview - - Review the bucket configuration before creating - - -
-
-
-

Bucket Name

-

{newBucketName}

-
-
-

Status

- Ready to Create -
-
-
- - - - -
-
+ - {/* Delete Bucket Dialog */} - - - - Delete Bucket - - Are you sure you want to delete "{selectedBucket?.name}"? This action cannot be - undone. - - - - - - - - +
); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1a7bb9f..05f68c3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null); - const [buckets, setBuckets] = useState([]); - const [clusterHealth, setClusterHealth] = useState(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 ( +
+
+
+
+
+

Loading dashboard...

+
+
+
+ ); + } return (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1b791f2..bbffe3f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8d8904f..0a5249d 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -8,38 +8,38 @@ export default { theme: { extend: { colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + background: 'var(--background)', + foreground: 'var(--foreground)', primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)', }, secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)', }, destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)', }, muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)', }, accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)', }, popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)', }, card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)', }, }, borderRadius: { diff --git a/go.mod b/go.mod index de5a28a..19d763b 100644 --- a/go.mod +++ b/go.mod @@ -3,49 +3,83 @@ module Noooste/garage-ui go 1.25.3 require ( - github.com/aws/aws-sdk-go-v2 v1.40.0 - github.com/aws/aws-sdk-go-v2/credentials v1.19.1 - github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 + github.com/Noooste/azuretls-client v1.12.9 + github.com/Noooste/swagger v1.2.0 + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/credentials v1.17.28 + github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 github.com/coreos/go-oidc/v3 v3.17.0 github.com/gofiber/fiber/v3 v3.0.0-rc.3 github.com/spf13/viper v1.21.0 + github.com/swaggo/swag v1.16.6 golang.org/x/oauth2 v0.33.0 ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Noooste/fhttp v1.0.15 // indirect + github.com/Noooste/go-socks4 v0.0.2 // indirect + github.com/Noooste/uquic-go v1.0.1 // indirect + github.com/Noooste/utls v1.3.20 // indirect + github.com/Noooste/websocket v1.0.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect github.com/aws/smithy-go v1.23.2 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gaukas/clienthellod v0.4.2 // indirect + github.com/gaukas/godicttls v0.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/spec v0.22.1 // indirect + github.com/go-openapi/swag/conv v0.25.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect + github.com/go-openapi/swag/jsonutils v0.25.3 // indirect + github.com/go-openapi/swag/loading v0.25.3 // indirect + github.com/go-openapi/swag/stringutils v0.25.3 // indirect + github.com/go-openapi/swag/typeutils v0.25.3 // indirect + github.com/go-openapi/swag/yamlutils v0.25.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofiber/schema v1.6.0 // indirect github.com/gofiber/utils/v2 v2.0.0-rc.2 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/refraction-networking/utls v1.8.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tinylib/msgp v1.5.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.68.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index cae427f..a06d0f6 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,115 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE= +github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU= +github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs= +github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE= +github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng= +github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE= +github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk= +github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM= +github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8= +github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A= +github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88= +github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= +github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA= +github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0 h1:8FshVvnV2sr9kOSAbOnc/vwVmmAwMjOedKH6JW2ddPM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.92.0/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw43CFqqaPB5w3W2c= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE= +github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA= +github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= +github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E= +github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw= +github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao= +github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y= +github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c= +github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w= +github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc= +github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg= +github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA= @@ -44,10 +118,17 @@ github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8= github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -58,14 +139,22 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= +github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4= @@ -80,10 +169,15 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= +github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -94,21 +188,46 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 273c2e2..f4a62eb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,6 +8,7 @@ import ( "strings" "Noooste/garage-ui/internal/config" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) diff --git a/internal/config/config.go b/internal/config/config.go index b0e91b9..bbe2c52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,13 +31,15 @@ type GarageConfig struct { SecretKey string `mapstructure:"secret_key"` UseSSL bool `mapstructure:"use_ssl"` ForcePathStyle bool `mapstructure:"force_path_style"` + AdminEndpoint string `mapstructure:"admin_endpoint"` + AdminToken string `mapstructure:"admin_token"` } // AuthConfig contains authentication configuration type AuthConfig struct { - Mode string `mapstructure:"mode"` // "none", "basic", or "oidc" - Basic BasicAuthConfig `mapstructure:"basic"` - OIDC OIDCConfig `mapstructure:"oidc"` + Mode string `mapstructure:"mode"` // "none", "basic", or "oidc" + Basic BasicAuthConfig `mapstructure:"basic"` + OIDC OIDCConfig `mapstructure:"oidc"` } // BasicAuthConfig contains basic authentication settings @@ -48,28 +50,28 @@ type BasicAuthConfig struct { // OIDCConfig contains OIDC authentication settings type OIDCConfig struct { - Enabled bool `mapstructure:"enabled"` - ProviderName string `mapstructure:"provider_name"` - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - Scopes []string `mapstructure:"scopes"` - IssuerURL string `mapstructure:"issuer_url"` - AuthURL string `mapstructure:"auth_url"` - TokenURL string `mapstructure:"token_url"` - UserinfoURL string `mapstructure:"userinfo_url"` - SkipIssuerCheck bool `mapstructure:"skip_issuer_check"` - SkipExpiryCheck bool `mapstructure:"skip_expiry_check"` - EmailAttribute string `mapstructure:"email_attribute"` - UsernameAttribute string `mapstructure:"username_attribute"` - NameAttribute string `mapstructure:"name_attribute"` - RoleAttributePath string `mapstructure:"role_attribute_path"` - AdminRole string `mapstructure:"admin_role"` - TLSSkipVerify bool `mapstructure:"tls_skip_verify"` - SessionMaxAge int `mapstructure:"session_max_age"` - CookieName string `mapstructure:"cookie_name"` - CookieSecure bool `mapstructure:"cookie_secure"` - CookieHTTPOnly bool `mapstructure:"cookie_http_only"` - CookieSameSite string `mapstructure:"cookie_same_site"` + Enabled bool `mapstructure:"enabled"` + ProviderName string `mapstructure:"provider_name"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + Scopes []string `mapstructure:"scopes"` + IssuerURL string `mapstructure:"issuer_url"` + AuthURL string `mapstructure:"auth_url"` + TokenURL string `mapstructure:"token_url"` + UserinfoURL string `mapstructure:"userinfo_url"` + SkipIssuerCheck bool `mapstructure:"skip_issuer_check"` + SkipExpiryCheck bool `mapstructure:"skip_expiry_check"` + EmailAttribute string `mapstructure:"email_attribute"` + UsernameAttribute string `mapstructure:"username_attribute"` + NameAttribute string `mapstructure:"name_attribute"` + RoleAttributePath string `mapstructure:"role_attribute_path"` + AdminRole string `mapstructure:"admin_role"` + TLSSkipVerify bool `mapstructure:"tls_skip_verify"` + SessionMaxAge int `mapstructure:"session_max_age"` + CookieName string `mapstructure:"cookie_name"` + CookieSecure bool `mapstructure:"cookie_secure"` + CookieHTTPOnly bool `mapstructure:"cookie_http_only"` + CookieSameSite string `mapstructure:"cookie_same_site"` } // CORSConfig contains CORS settings for frontend communication @@ -143,6 +145,12 @@ func (c *Config) Validate() error { if c.Garage.SecretKey == "" { return fmt.Errorf("garage secret_key is required") } + if c.Garage.AdminEndpoint == "" { + return fmt.Errorf("garage admin_endpoint is required") + } + if c.Garage.AdminToken == "" { + return fmt.Errorf("garage admin_token is required") + } // Validate auth mode if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { diff --git a/internal/handlers/buckets.go b/internal/handlers/buckets.go index 6a31faa..8390a89 100644 --- a/internal/handlers/buckets.go +++ b/internal/handlers/buckets.go @@ -3,39 +3,96 @@ package handlers import ( "Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/services" + "github.com/gofiber/fiber/v3" ) // BucketHandler handles bucket-related operations type BucketHandler struct { - s3Service *services.S3Service + adminService *services.GarageAdminService + s3Service *services.S3Service } // NewBucketHandler creates a new bucket handler -func NewBucketHandler(s3Service *services.S3Service) *BucketHandler { +func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler { return &BucketHandler{ - s3Service: s3Service, + adminService: adminService, + s3Service: s3Service, } } -// ListBuckets returns all buckets -// GET /api/v1/buckets +// ListBuckets lists all buckets +// +// @Summary List all buckets +// @Description Retrieves a list of all buckets in the Garage storage system with object count and size +// @Tags Buckets +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets" +// @Router /api/v1/buckets [get] func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { ctx := c.Context() - // List all buckets from Garage - buckets, err := h.s3Service.ListBuckets(ctx) + // List all buckets from Garage Admin API + adminBuckets, err := h.adminService.ListBuckets(ctx) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()), ) } - return c.JSON(models.SuccessResponse(buckets)) + // Convert admin bucket response to BucketInfo + buckets := make([]models.BucketInfo, 0, len(adminBuckets)) + for _, adminBucket := range adminBuckets { + // Get the bucket name from global aliases + var bucketName string + if len(adminBucket.GlobalAliases) > 0 { + bucketName = adminBucket.GlobalAliases[0] + } else { + // Skip buckets without global aliases + continue + } + + bucketInfo := models.BucketInfo{ + Name: bucketName, + CreationDate: adminBucket.Created, + Region: "", // Garage doesn't have regions + } + + // Try to get bucket statistics (object count and size) + // This is done asynchronously to avoid blocking the response + // If it fails, we still return the bucket info without stats + stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName) + if err == nil && stats != nil { + bucketInfo.ObjectCount = &stats.ObjectCount + bucketInfo.Size = &stats.TotalSize + } + + buckets = append(buckets, bucketInfo) + } + + response := models.BucketListResponse{ + Buckets: buckets, + Count: len(buckets), + } + + return c.JSON(models.SuccessResponse(response)) } // CreateBucket creates a new bucket -// POST /api/v1/buckets +// +// @Summary Create a new bucket +// @Description Creates a new bucket in the Garage storage system +// @Tags Buckets +// @Accept json +// @Produce json +// @Param payload body models.CreateBucketRequest true "Bucket creation payload" +// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required" +// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket" +// @Router /api/v1/buckets [post] func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { ctx := c.Context() @@ -55,21 +112,24 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { } // Check if bucket already exists - exists, err := h.s3Service.BucketExists(ctx, req.Name) + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), ) } - if exists { + if bucketInfo != nil { return c.Status(fiber.StatusConflict).JSON( models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"), ) } // Create the bucket - if err := h.s3Service.CreateBucket(ctx, req.Name); err != nil { + createBucketReq := models.CreateBucketAdminRequest{ + GlobalAlias: &req.Name, + } + if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()), ) @@ -85,7 +145,18 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { } // DeleteBucket deletes a bucket -// DELETE /api/v1/buckets/:name +// +// @Summary Delete a bucket +// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion. +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket to delete" +// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket" +// @Router /api/v1/buckets/{name} [delete] func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error { ctx := c.Context() @@ -97,22 +168,22 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error { ) } - // Check if bucket exists - exists, err := h.s3Service.BucketExists(ctx, bucketName) + // Check if bucket already exists + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), ) } - if !exists { + if bucketInfo == nil { return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), ) } // Delete the bucket - if err := h.s3Service.DeleteBucket(ctx, bucketName); err != nil { + if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()), ) @@ -128,7 +199,18 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error { } // GetBucketInfo returns information about a specific bucket -// GET /api/v1/buckets/:name +// +// @Summary Get bucket information +// @Description Retrieves detailed information about a specific bucket including creation date and region +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket to retrieve information for" +// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information" +// @Router /api/v1/buckets/{name} [get] func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error { ctx := c.Context() @@ -140,36 +222,95 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error { ) } - // Check if bucket exists - exists, err := h.s3Service.BucketExists(ctx, bucketName) + // Check if bucket already exists + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), ) } - if !exists { + if bucketInfo == nil { return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), ) } - // List all buckets to find this one and get its info - buckets, err := h.s3Service.ListBuckets(ctx) + return c.JSON(models.SuccessResponse(bucketInfo)) +} + +// GrantBucketPermission grants permissions for an access key on a bucket +// +// @Summary Grant bucket permissions +// @Description Grants read/write/owner permissions for an access key on a specific bucket +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket" +// @Param request body models.GrantBucketPermissionRequest true "Permission grant request" +// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to grant permissions" +// @Router /api/v1/buckets/{name}/permissions [post] +func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("name") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Parse request body + var req models.GrantBucketPermissionRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + // Validate access key ID + if req.AccessKeyID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"), + ) + } + + // Get bucket info to retrieve bucket ID + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()), ) } - // Find the specific bucket - for _, bucket := range buckets.Buckets { - if bucket.Name == bucketName { - return c.JSON(models.SuccessResponse(bucket)) - } + if bucketInfo == nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), + ) } - return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), - ) + // Build the permission request for Garage Admin API + permRequest := models.BucketKeyPermRequest{ + BucketID: bucketInfo.ID, + AccessKeyID: req.AccessKeyID, + Permissions: models.BucketKeyPermission{ + Read: req.Permissions.Read, + Write: req.Permissions.Write, + Owner: req.Permissions.Owner, + }, + } + + // Grant permissions using Garage Admin API + result, err := h.adminService.AllowBucketKey(ctx, permRequest) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(result)) } diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go new file mode 100644 index 0000000..0bbede7 --- /dev/null +++ b/internal/handlers/cluster.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// ClusterHandler handles cluster management operations +type ClusterHandler struct { + adminService *services.GarageAdminService +} + +// NewClusterHandler creates a new cluster handler +func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler { + return &ClusterHandler{ + adminService: adminService, + } +} + +// GetHealth returns the health status of the cluster +// +// @Summary Get cluster health +// @Description Retrieves the overall health status of the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health" +// @Router /api/v1/cluster/health [get] +func (h *ClusterHandler) GetHealth(c fiber.Ctx) error { + ctx := c.Context() + + health, err := h.adminService.GetClusterHealth(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(health)) +} + +// GetStatus returns the status of the cluster +// +// @Summary Get cluster status +// @Description Retrieves the current status of the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status" +// @Router /api/v1/cluster/status [get] +func (h *ClusterHandler) GetStatus(c fiber.Ctx) error { + ctx := c.Context() + + status, err := h.adminService.GetClusterStatus(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(status)) +} + +// GetStatistics returns global cluster statistics +// GET /api/v1/cluster/statistics +func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error { + ctx := c.Context() + + stats, err := h.adminService.GetClusterStatistics(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(stats)) +} + +// GetNodeInfo returns information about a specific node +// +// @Summary Get node information +// @Description Retrieves detailed information about a specific node in the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Param node_id path string true "ID of the node to retrieve information for" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information" +// @Router /api/v1/cluster/nodes/{node_id} [get] +func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error { + ctx := c.Context() + nodeID := c.Params("node_id") + + if nodeID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), + ) + } + + info, err := h.adminService.GetNodeInfo(ctx, nodeID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(info)) +} + +// GetNodeStatistics returns statistics for a specific node +// +// @Summary Get node statistics +// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Param node_id path string true "ID of the node to retrieve statistics for" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics" +// @Router /api/v1/cluster/nodes/{node_id}/statistics [get] +func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error { + ctx := c.Context() + nodeID := c.Params("node_id") + + if nodeID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), + ) + } + + stats, err := h.adminService.GetNodeStatistics(ctx, nodeID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(stats)) +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go index 9e54904..323ec08 100644 --- a/internal/handlers/health.go +++ b/internal/handlers/health.go @@ -4,6 +4,7 @@ import ( "time" "Noooste/garage-ui/internal/models" + "github.com/gofiber/fiber/v3" ) @@ -20,6 +21,14 @@ func NewHealthHandler(version string) *HealthHandler { } // Check returns the health status of the service +// +// @Summary Health check +// @Description Returns the health status of the API service along with version information +// @Tags Health +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy" +// @Router /api/v1/health [get] func (h *HealthHandler) Check(c fiber.Ctx) error { response := models.HealthResponse{ Status: "healthy", diff --git a/internal/handlers/monitoring.go b/internal/handlers/monitoring.go new file mode 100644 index 0000000..c8a5ce0 --- /dev/null +++ b/internal/handlers/monitoring.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// MonitoringHandler handles monitoring operations +type MonitoringHandler struct { + adminService *services.GarageAdminService + s3Service *services.S3Service +} + +// NewMonitoringHandler creates a new monitoring handler +func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler { + return &MonitoringHandler{ + adminService: adminService, + s3Service: s3Service, + } +} + +// GetMetrics retrieves system metrics from the Admin API +// +// @Summary Get system metrics +// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes +// @Tags Monitoring +// @Accept json +// @Produce text/plain +// @Success 200 {string} string "System metrics in plain text format" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics" +// @Router /api/v1/monitoring/metrics [get] +func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error { + ctx := c.Context() + + metrics, err := h.adminService.GetMetrics(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()), + ) + } + + // Return metrics as plain text + c.Set("Content-Type", "text/plain; charset=utf-8") + return c.SendString(metrics) +} + +// CheckAdminHealth checks if the Admin API is reachable +// +// @Summary Check Admin API health +// @Description Performs a health check on the Garage Admin API to verify connectivity and availability +// @Tags Monitoring +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy" +// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed" +// @Router /api/v1/monitoring/admin-health [get] +func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error { + ctx := c.Context() + + err := h.adminService.HealthCheck(ctx) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(map[string]interface{}{ + "status": "healthy", + "message": "Admin API is reachable", + })) +} + +// GetDashboardMetrics retrieves aggregated dashboard metrics +// +// @Summary Get dashboard metrics +// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics +// @Tags Monitoring +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics" +// @Router /api/v1/monitoring/dashboard [get] +func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket list + buckets, err := h.adminService.ListBuckets(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()), + ) + } + + // Calculate aggregated metrics + var totalSize int64 + var totalObjects int64 + usageByBucket := make([]models.BucketUsage, 0) + + for _, bucket := range buckets { + // Get bucket info to calculate size and object count + bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID) + if err != nil { + continue // Skip buckets we can't access + } + + // Get size and object count from bucket info + bucketSize := bucketInfo.Bytes + objectCount := bucketInfo.Objects + + totalSize += bucketSize + totalObjects += objectCount + + // Get bucket name from aliases + bucketName := bucket.ID + if len(bucket.LocalAliases) > 0 { + bucketName = bucket.LocalAliases[0].Alias + } else if len(bucket.GlobalAliases) > 0 { + bucketName = bucket.GlobalAliases[0] + } + + usageByBucket = append(usageByBucket, models.BucketUsage{ + BucketName: bucketName, + Size: bucketSize, + ObjectCount: objectCount, + }) + } + + // Calculate percentages + for i := range usageByBucket { + if totalSize > 0 { + usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100 + } + } + + dashboardMetrics := models.DashboardMetrics{ + TotalSize: totalSize, + ObjectCount: totalObjects, + BucketCount: len(buckets), + UsageByBucket: usageByBucket, + RequestMetrics: models.RequestMetrics{ + GetRequests: 0, + PutRequests: 0, + DeleteRequests: 0, + ListRequests: 0, + Period: "last-24h", + }, + } + + return c.JSON(models.SuccessResponse(dashboardMetrics)) +} diff --git a/internal/handlers/objects.go b/internal/handlers/objects.go index 9ee1e40..25b3f72 100644 --- a/internal/handlers/objects.go +++ b/internal/handlers/objects.go @@ -2,10 +2,12 @@ package handlers import ( "io" + "strconv" "time" "Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/services" + "github.com/gofiber/fiber/v3" ) @@ -21,8 +23,22 @@ func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler { } } -// ListObjects returns all objects in a bucket -// GET /api/v1/buckets/:bucket/objects +// ListObjects lists objects in a bucket with optional filtering and pagination +// +// @Summary List objects in a bucket +// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket to list objects from" +// @Param prefix query string false "Filter objects by prefix" +// @Param max_keys query int false "Maximum number of objects to return (default: 100)" +// @Param continuation_token query string false "Token for pagination to retrieve next page of results" +// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects" +// @Router /api/v1/buckets/{bucket}/objects [get] func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { ctx := c.Context() @@ -34,26 +50,20 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { ) } - // Get query parameters for filtering + // Get query parameters for filtering and pagination prefix := c.Query("prefix", "") - maxKeys := c.QueryInt("max_keys", 1000) + continuationToken := c.Query("continuation_token", "") - // Check if bucket exists - exists, err := h.s3Service.BucketExists(ctx, bucketName) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON( - models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), - ) - } - - if !exists { - return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), + maxKeysStr := c.Query("max_keys", "100") + maxKeys, err := strconv.Atoi(maxKeysStr) + if err != nil || maxKeys <= 0 { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"), ) } // List objects in the bucket - objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys) + objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON( models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()), @@ -63,8 +73,21 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(objects)) } -// UploadObject uploads a file to a bucket -// POST /api/v1/buckets/:bucket/objects +// UploadObject uploads an object to a bucket +// +// @Summary Upload object to bucket +// @Description Uploads an object to the specified bucket using multipart/form-data +// @Tags Objects +// @Accept multipart/form-data +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the object to" +// @Param file formData file true "File to upload" +// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" +// @Router /api/v1/buckets/{bucket}/objects [post] func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { ctx := c.Context() @@ -76,20 +99,6 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { ) } - // Check if bucket exists - exists, err := h.s3Service.BucketExists(ctx, bucketName) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON( - models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), - ) - } - - if !exists { - return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), - ) - } - // Get file from multipart form file, err := c.FormFile("file") if err != nil { @@ -128,8 +137,20 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult)) } -// GetObject downloads an object from a bucket -// GET /api/v1/buckets/:bucket/objects/:key +// GetObject retrieves an object from a bucket +// +// @Summary Get object from bucket +// @Description Retrieves an object stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce application/octet-stream +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Param download query bool false "Set to true to download the object as an attachment" +// @Success 200 {file} binary "Successfully retrieved the object" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Router /api/v1/buckets/{bucket}/objects/{key} [get] func (h *ObjectHandler) GetObject(c fiber.Ctx) error { ctx := c.Context() @@ -168,7 +189,19 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error { } // DeleteObject deletes an object from a bucket -// DELETE /api/v1/buckets/:bucket/objects/:key +// +// @Summary Delete object from bucket +// @Description Deletes an object stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object" +// @Router /api/v1/buckets/{bucket}/objects/{key} [delete] func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { ctx := c.Context() @@ -214,7 +247,18 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { } // GetObjectMetadata returns metadata for an object without downloading it -// HEAD /api/v1/buckets/:bucket/objects/:key +// +// @Summary Get object metadata +// @Description Retrieves metadata information about an object without downloading the actual content +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get] func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { ctx := c.Context() @@ -239,8 +283,21 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { return c.JSON(models.SuccessResponse(metadata)) } -// GetPresignedURL generates a temporary pre-signed URL for an object -// POST /api/v1/buckets/:bucket/objects/:key/presign +// GetPresignedURL generates a pre-signed URL for accessing an object +// +// @Summary Get pre-signed URL for object +// @Description Generates a pre-signed URL that allows temporary access to the specified object +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)" +// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL" +// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get] func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { ctx := c.Context() @@ -255,7 +312,15 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { } // Get expiration time from query parameter (default: 1 hour) - expiresIn := c.QueryInt("expires_in", 3600) + expiresInStr := c.Query("expires_in", "3600") + expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()), + ) + } + + // Validate expiration time (1 second to 7 days) if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days return c.Status(fiber.StatusBadRequest).JSON( models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"), @@ -284,18 +349,30 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { ) } - response := map[string]interface{}{ - "url": url, - "expires_in": expiresIn, - "bucket": bucketName, - "key": key, + response := models.PresignedURLResponse{ + URL: url, + ExpiresIn: expiresIn, + Bucket: bucketName, + Key: key, } return c.JSON(models.SuccessResponse(response)) } // DeleteMultipleObjects deletes multiple objects from a bucket -// DELETE /api/v1/buckets/:bucket/objects +// +// @Summary Delete multiple objects from bucket +// @Description Deletes multiple objects stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the objects" +// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects" +// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post] func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { ctx := c.Context() @@ -307,9 +384,10 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { ) } - // Parse request body to get keys + // Parse request body to get keys and optional prefix var req struct { - Keys []string `json:"keys"` + Keys []string `json:"keys"` + Prefix string `json:"prefix,omitempty"` } if err := c.Bind().JSON(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON( @@ -330,17 +408,31 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { ) } - response := map[string]interface{}{ - "bucket": bucketName, - "deleted": len(req.Keys), - "keys": req.Keys, + response := models.ObjectDeleteMultipleResponse{ + Bucket: bucketName, + Deleted: len(req.Keys), + Keys: req.Keys, } return c.JSON(models.SuccessResponse(response)) } // UploadObjectStream uploads an object from request body stream (for large files) -// PUT /api/v1/buckets/:bucket/objects/:key +// +// @Summary Upload object via stream +// @Description Uploads an object directly from the request body stream, suitable for large files +// @Tags Objects +// @Accept application/octet-stream +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the object to" +// @Param key path string true "Object key (path in bucket)" +// @Param Content-Type header string false "Content type of the object being uploaded" +// @Param body body string true "Raw binary data of the object" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" +// @Router /api/v1/buckets/{bucket}/objects/{key} [put] func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { ctx := c.Context() @@ -354,20 +446,6 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { ) } - // Check if bucket exists - exists, err := h.s3Service.BucketExists(ctx, bucketName) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON( - models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), - ) - } - - if !exists { - return c.Status(fiber.StatusNotFound).JSON( - models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket not found"), - ) - } - // Get content type from header contentType := c.Get("Content-Type", "application/octet-stream") @@ -384,3 +462,127 @@ func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult)) } + +// UploadMultipleObjects uploads multiple objects to a bucket +// +// @Summary Upload multiple objects to bucket +// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop. +// @Tags Objects +// @Accept multipart/form-data +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the objects to" +// @Param files formData file true "Files to upload (can be multiple)" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects" +// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post] +func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("bucket") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Parse multipart form to get all files + form, err := c.MultipartForm() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()), + ) + } + + // Get all files from the form (they should all be under "files" field) + files := form.File["files"] + if len(files) == 0 { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"), + ) + } + + // Prepare upload data structure + uploadFiles := make([]struct { + Key string + Body io.Reader + ContentType string + }, len(files)) + + // Open all files and prepare for upload + for i, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()), + ) + } + defer file.Close() + + // Use filename as the key + key := fileHeader.Filename + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + uploadFiles[i] = struct { + Key string + Body io.Reader + ContentType string + }{ + Key: key, + Body: file, + ContentType: contentType, + } + } + + // Upload all files using the service method + results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles) + + // Process results and categorize successes and failures + var successFiles []models.ObjectUploadResult + var failedFiles []models.ObjectUploadFailedResult + successCount := 0 + failureCount := 0 + + for _, result := range results { + if result.Success { + successCount++ + successFiles = append(successFiles, models.ObjectUploadResult{ + Key: result.Key, + ETag: result.ETag, + Size: result.Size, + ContentType: result.ContentType, + }) + } else { + failureCount++ + failedFiles = append(failedFiles, models.ObjectUploadFailedResult{ + Key: result.Key, + Error: result.Error.Error(), + ContentType: result.ContentType, + }) + } + } + + response := models.ObjectUploadMultipleResponse{ + Bucket: bucketName, + TotalFiles: len(files), + SuccessCount: successCount, + FailureCount: failureCount, + SuccessFiles: successFiles, + FailedFiles: failedFiles, + } + + // Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed + statusCode := fiber.StatusCreated + if failureCount > 0 && successCount > 0 { + statusCode = fiber.StatusMultiStatus // 207 + } else if failureCount > 0 && successCount == 0 { + statusCode = fiber.StatusInternalServerError + } + + return c.Status(statusCode).JSON(models.SuccessResponse(response)) +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 456fe0c..3319fdf 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -1,46 +1,123 @@ package handlers import ( + "time" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + "github.com/gofiber/fiber/v3" ) -// UserHandler handles user/key management operations -// Note: Garage user management typically requires administrative API access -// This is a placeholder implementation that you'll need to extend based on -// your Garage setup and how you manage keys/users +// UserHandler handles user/key management operations using Garage Admin API type UserHandler struct { - // In a real implementation, you might have a Garage admin client here - // or interact with Garage's administrative API + adminService *services.GarageAdminService } // NewUserHandler creates a new user handler -func NewUserHandler() *UserHandler { - return &UserHandler{} +func NewUserHandler(adminService *services.GarageAdminService) *UserHandler { + return &UserHandler{ + adminService: adminService, + } } -// ListUsers returns all users/keys -// GET /api/v1/users +// ListUsers lists all users/access keys +// +// @Summary List all users +// @Description Retrieves a list of all users/access keys +// @Tags Users +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users" +// @Router /api/v1/users [get] func (h *UserHandler) ListUsers(c fiber.Ctx) error { - // NOTE: This is a placeholder implementation - // Garage manages keys/users through its administrative RPC interface - // You'll need to implement this based on your Garage setup + ctx := c.Context() - // For now, return a not implemented response - return c.Status(fiber.StatusNotImplemented).JSON( - models.ErrorResponse(models.ErrCodeInternalError, - "User management not yet implemented. Requires Garage Admin API integration."), - ) + keys, err := h.adminService.ListKeys(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()), + ) + } + + // Convert to UserInfo format + users := make([]models.UserInfo, 0, len(keys)) + for _, key := range keys { + // Get full key info to retrieve bucket permissions + keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false) + if err != nil { + // If we can't get full info, skip this key or use basic info + continue + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status based on expiration + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + users = append(users, models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + }) + } + + return c.JSON(models.SuccessResponse(models.UserListResponse{ + Users: users, + Count: len(users), + })) } -// CreateUser creates a new user/key pair -// POST /api/v1/users +// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format +func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission { + permissions := make([]models.BucketPermission, 0, len(buckets)) + + for _, bucket := range buckets { + // Get bucket name from aliases + var bucketName string + if len(bucket.GlobalAliases) > 0 { + bucketName = bucket.GlobalAliases[0] + } else if len(bucket.LocalAliases) > 0 { + bucketName = bucket.LocalAliases[0] + } else { + bucketName = bucket.ID + } + + // Create bucket permission with simple read/write/owner flags + permissions = append(permissions, models.BucketPermission{ + BucketID: bucket.ID, + BucketName: bucketName, + Read: bucket.Permissions.Read, + Write: bucket.Permissions.Write, + Owner: bucket.Permissions.Owner, + }) + } + + return permissions +} + +// CreateUser creates a new user/access key +// +// @Summary Create a new user +// @Description Creates a new user/access key with optional name +// @Tags Users +// @Accept json +// @Produce json +// @Param request body models.CreateUserRequest true "User creation request" +// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user" +// @Router /api/v1/users [post] func (h *UserHandler) CreateUser(c fiber.Ctx) error { - // NOTE: This is a placeholder implementation - // To implement this, you need to: - // 1. Connect to Garage's admin RPC interface - // 2. Call the appropriate key creation endpoint - // 3. Return the generated access key and secret key + ctx := c.Context() var req models.CreateUserRequest if err := c.Bind().JSON(&req); err != nil { @@ -49,16 +126,57 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error { ) } - return c.Status(fiber.StatusNotImplemented).JSON( - models.ErrorResponse(models.ErrCodeInternalError, - "User creation not yet implemented. Requires Garage Admin API integration."), - ) + // Prepare create key request + createReq := models.CreateKeyRequest{} + if req.Name != "" { + createReq.Name = &req.Name + } + + // Create the key + keyInfo, err := h.adminService.CreateKey(ctx, createReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + SecretKey: keyInfo.SecretAccessKey, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo)) } -// DeleteUser deletes a user/key -// DELETE /api/v1/users/:access_key +// DeleteUser deletes a user/access key +// +// @Summary Delete a user +// @Description Deletes a specific user/access key +// @Tags Users +// @Produce json +// @Param access_key path string true "Access key of the user to delete" +// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user" +// @Router /api/v1/users/{access_key} [delete] func (h *UserHandler) DeleteUser(c fiber.Ctx) error { - // NOTE: This is a placeholder implementation + ctx := c.Context() accessKey := c.Params("access_key") if accessKey == "" { @@ -67,15 +185,33 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error { ) } - return c.Status(fiber.StatusNotImplemented).JSON( - models.ErrorResponse(models.ErrCodeInternalError, - "User deletion not yet implemented. Requires Garage Admin API integration."), - ) + // Delete the key + err := h.adminService.DeleteKey(ctx, accessKey) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(map[string]interface{}{ + "access_key": accessKey, + "deleted": true, + })) } -// GetUser returns information about a specific user/key -// GET /api/v1/users/:access_key +// GetUser retrieves information about a specific user/access key +// +// @Summary Get user information +// @Description Retrieves information about a specific user/access key +// @Tags Users +// @Produce json +// @Param access_key path string true "Access key of the user to retrieve" +// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info" +// @Router /api/v1/users/{access_key} [get] func (h *UserHandler) GetUser(c fiber.Ctx) error { + ctx := c.Context() accessKey := c.Params("access_key") if accessKey == "" { @@ -84,15 +220,52 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error { ) } - return c.Status(fiber.StatusNotImplemented).JSON( - models.ErrorResponse(models.ErrCodeInternalError, - "User info not yet implemented. Requires Garage Admin API integration."), - ) + // Get key information (without secret key) + keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.JSON(models.SuccessResponse(userInfo)) } // UpdateUserPermissions updates user permissions -// PATCH /api/v1/users/:access_key +// +// @Summary Update user permissions +// @Description Updates the permissions and settings for a specific user/access key +// @Tags Users +// @Accept json +// @Produce json +// @Param access_key path string true "Access key of the user to update" +// @Param request body models.UpdateUserRequest true "User update request with new permissions" +// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user" +// @Router /api/v1/users/{access_key} [patch] func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error { + ctx := c.Context() accessKey := c.Params("access_key") if accessKey == "" { @@ -108,56 +281,61 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error { ) } - return c.Status(fiber.StatusNotImplemented).JSON( - models.ErrorResponse(models.ErrCodeInternalError, - "User permission update not yet implemented. Requires Garage Admin API integration."), - ) -} + // Prepare update request + updateReq := models.UpdateKeyRequest{} -/* -IMPLEMENTATION NOTES FOR USER MANAGEMENT: - -Garage uses an administrative RPC interface for managing keys and buckets. -To implement user management, you need to: - -1. Install garage-admin client or use HTTP RPC calls -2. Connect to Garage's admin port (typically 3903) -3. Implement the following operations: - - - List keys: GET /v1/key - - Create key: POST /v1/key - - Get key info: GET /v1/key?id= - - Delete key: DELETE /v1/key?id= - - Update key: POST /v1/key?id= - -Example using HTTP client: - -import ( - "bytes" - "encoding/json" - "net/http" -) - -type GarageAdminClient struct { - baseURL string - token string -} - -func (g *GarageAdminClient) ListKeys() ([]KeyInfo, error) { - req, _ := http.NewRequest("GET", g.baseURL+"/v1/key", nil) - req.Header.Set("Authorization", "Bearer "+g.token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err + // Handle status change (activate/deactivate) + if req.Status != nil { + if *req.Status == "inactive" { + // Deactivate by setting expiration to the past + pastTime := time.Now().Add(-24 * time.Hour) + updateReq.Expiration = &pastTime + updateReq.NeverExpires = false + } else if *req.Status == "active" { + // Activate by removing expiration (set to never expire) + updateReq.NeverExpires = true + } } - defer resp.Body.Close() - var keys []KeyInfo - json.NewDecoder(resp.Body).Decode(&keys) - return keys, nil + // Handle explicit expiration date setting + if req.Expiration != nil && *req.Expiration != "" { + expirationTime, err := time.Parse(time.RFC3339, *req.Expiration) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()), + ) + } + updateReq.Expiration = &expirationTime + updateReq.NeverExpires = false + } + + // Update the key + keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.JSON(models.SuccessResponse(userInfo)) } - -For more information, see: -https://garagehq.deuxfleurs.fr/documentation/reference-manual/admin-api/ -*/ diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 315384d..8411981 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,6 +4,7 @@ import ( "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" + "github.com/gofiber/fiber/v3" ) diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index 0abc4d1..bac15f2 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -4,6 +4,7 @@ import ( "strings" "Noooste/garage-ui/internal/config" + "github.com/gofiber/fiber/v3" ) diff --git a/internal/models/garage_admin.go b/internal/models/garage_admin.go new file mode 100644 index 0000000..7ca63fd --- /dev/null +++ b/internal/models/garage_admin.go @@ -0,0 +1,263 @@ +package models + +import "time" + +// ==================================== +// Access Key Models +// ==================================== + +// GarageKeyInfo represents detailed information about a Garage access key +type GarageKeyInfo struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Expired bool `json:"expired"` + SecretAccessKey *string `json:"secretAccessKey,omitempty"` + Permissions KeyPermissions `json:"permissions"` + Buckets []KeyBucketInfo `json:"buckets"` + Created *time.Time `json:"created,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` +} + +// KeyPermissions represents permissions for an access key +type KeyPermissions struct { + CreateBucket bool `json:"createBucket"` +} + +// KeyBucketInfo represents bucket information associated with a key +type KeyBucketInfo struct { + ID string `json:"id"` + GlobalAliases []string `json:"globalAliases"` + LocalAliases []string `json:"localAliases"` + Permissions BucketKeyPermission `json:"permissions"` +} + +// BucketKeyPermission represents permissions a key has on a specific bucket +type BucketKeyPermission struct { + Read bool `json:"read"` + Write bool `json:"write"` + Owner bool `json:"owner"` +} + +// CreateKeyRequest represents the request to create a new access key +type CreateKeyRequest struct { + Name *string `json:"name,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + NeverExpires bool `json:"neverExpires,omitempty"` + Allow *KeyPermissions `json:"allow,omitempty"` + Deny *KeyPermissions `json:"deny,omitempty"` +} + +// UpdateKeyRequest represents the request to update an access key +type UpdateKeyRequest struct { + Name *string `json:"name,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + NeverExpires bool `json:"neverExpires,omitempty"` + Allow *KeyPermissions `json:"allow,omitempty"` + Deny *KeyPermissions `json:"deny,omitempty"` +} + +// ImportKeyRequest represents the request to import an existing key +type ImportKeyRequest struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + Name *string `json:"name,omitempty"` +} + +// ListKeysResponseItem represents a single key in the list response +type ListKeysResponseItem struct { + ID string `json:"id"` + Name string `json:"name"` + Expired bool `json:"expired"` + Created *time.Time `json:"created,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` +} + +// ==================================== +// Bucket Models (Admin API) +// ==================================== + +// GarageBucketInfo represents detailed information about a bucket from Admin API +type GarageBucketInfo struct { + ID string `json:"id"` + Created time.Time `json:"created"` + GlobalAliases []string `json:"globalAliases"` + WebsiteAccess bool `json:"websiteAccess"` + WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"` + Keys []BucketKeyInfo `json:"keys"` + Objects int64 `json:"objects"` + Bytes int64 `json:"bytes"` + UnfinishedUploads int64 `json:"unfinishedUploads"` + UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"` + UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` + UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` + Quotas *BucketQuotas `json:"quotas,omitempty"` +} + +// BucketWebsiteConfig represents website configuration for a bucket +type BucketWebsiteConfig struct { + IndexDocument string `json:"indexDocument"` + ErrorDocument *string `json:"errorDocument,omitempty"` +} + +// BucketQuotas represents quota settings for a bucket +type BucketQuotas struct { + MaxSize *int64 `json:"maxSize,omitempty"` + MaxObjects *int64 `json:"maxObjects,omitempty"` +} + +// BucketKeyInfo represents key information associated with a bucket +type BucketKeyInfo struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Permissions BucketKeyPermission `json:"permissions"` + BucketLocalAliases []string `json:"bucketLocalAliases"` +} + +// CreateBucketAdminRequest represents the request to create a bucket via Admin API +type CreateBucketAdminRequest struct { + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"` +} + +// CreateBucketLocalAlias represents local alias configuration when creating a bucket +type CreateBucketLocalAlias struct { + AccessKeyID string `json:"accessKeyId"` + Alias string `json:"alias"` + Allow *BucketKeyPermission `json:"allow,omitempty"` +} + +// UpdateBucketRequest represents the request to update bucket settings +type UpdateBucketRequest struct { + WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"` + Quotas *BucketQuotas `json:"quotas,omitempty"` +} + +// UpdateBucketWebsiteAccess represents website access settings update +type UpdateBucketWebsiteAccess struct { + Enabled bool `json:"enabled"` + IndexDocument *string `json:"indexDocument,omitempty"` + ErrorDocument *string `json:"errorDocument,omitempty"` +} + +// ListBucketsResponseItem represents a single bucket in the list response +type ListBucketsResponseItem struct { + ID string `json:"id"` + Created time.Time `json:"created"` + GlobalAliases []string `json:"globalAliases"` + LocalAliases []BucketLocalAlias `json:"localAliases"` +} + +// BucketLocalAlias represents a local alias for a bucket +type BucketLocalAlias struct { + AccessKeyID string `json:"accessKeyId"` + Alias string `json:"alias"` +} + +// ==================================== +// Bucket Alias Models +// ==================================== + +// AddBucketAliasRequest represents the request to add a bucket alias +type AddBucketAliasRequest struct { + BucketID string `json:"bucketId"` + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *string `json:"localAlias,omitempty"` + AccessKeyID *string `json:"accessKeyId,omitempty"` +} + +// RemoveBucketAliasRequest represents the request to remove a bucket alias +type RemoveBucketAliasRequest struct { + BucketID string `json:"bucketId"` + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *string `json:"localAlias,omitempty"` + AccessKeyID *string `json:"accessKeyId,omitempty"` +} + +// ==================================== +// Permission Models +// ==================================== + +// BucketKeyPermRequest represents a request to change bucket-key permissions +type BucketKeyPermRequest struct { + BucketID string `json:"bucketId"` + AccessKeyID string `json:"accessKeyId"` + Permissions BucketKeyPermission `json:"permissions"` +} + +// ==================================== +// Cluster Models +// ==================================== + +// ClusterHealth represents the health status of the cluster +type ClusterHealth struct { + Status string `json:"status"` + KnownNodes int `json:"knownNodes"` + ConnectedNodes int `json:"connectedNodes"` + StorageNodes int `json:"storageNodes"` + StorageNodesUp int `json:"storageNodesUp"` + Partitions int `json:"partitions"` + PartitionsQuorum int `json:"partitionsQuorum"` + PartitionsAllOk int `json:"partitionsAllOk"` +} + +// ClusterStatus represents the current status of the cluster +type ClusterStatus struct { + LayoutVersion int `json:"layoutVersion"` + Nodes []NodeInfo `json:"nodes"` +} + +// ClusterStatistics represents global cluster statistics +type ClusterStatistics struct { + Freeform string `json:"freeform"` +} + +// ==================================== +// Node Models +// ==================================== + +// NodeInfo represents information about a cluster node +type NodeInfo struct { + ID string `json:"id"` + IsUp bool `json:"isUp"` + LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"` + Hostname *string `json:"hostname,omitempty"` + Addr *string `json:"addr,omitempty"` + GarageVersion *string `json:"garageVersion,omitempty"` + Role *NodeRole `json:"role,omitempty"` + Draining bool `json:"draining"` + DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"` + MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"` +} + +// NodeRole represents the role assigned to a node +type NodeRole struct { + Zone string `json:"zone"` + Capacity *int64 `json:"capacity,omitempty"` + Tags []string `json:"tags"` +} + +// FreeSpaceInfo represents disk space information +type FreeSpaceInfo struct { + Available int64 `json:"available"` + Total int64 `json:"total"` +} + +// NodeInfoResponse represents the response for GetNodeInfo +type NodeInfoResponse struct { + NodeID string `json:"nodeId"` + GarageVersion string `json:"garageVersion"` + RustVersion string `json:"rustVersion"` + DBEngine string `json:"dbEngine"` + GarageFeatures []string `json:"garageFeatures,omitempty"` +} + +// NodeStatisticsResponse represents the response for GetNodeStatistics +type NodeStatisticsResponse struct { + Freeform string `json:"freeform"` +} + +// MultiNodeResponse represents responses from multiple nodes +type MultiNodeResponse struct { + Success map[string]interface{} `json:"success"` + Error map[string]string `json:"error"` +} diff --git a/internal/models/requests.go b/internal/models/requests.go index 0857375..34f05a0 100644 --- a/internal/models/requests.go +++ b/internal/models/requests.go @@ -6,6 +6,12 @@ type CreateBucketRequest struct { Region string `json:"region,omitempty"` } +// GrantBucketPermissionRequest represents a request to grant permissions on a bucket +type GrantBucketPermissionRequest struct { + AccessKeyID string `json:"accessKeyId" validate:"required"` + Permissions BucketKeyPermission `json:"permissions" validate:"required"` +} + // DeleteBucketRequest represents a request to delete a bucket type DeleteBucketRequest struct { Name string `json:"name" validate:"required"` @@ -13,10 +19,10 @@ type DeleteBucketRequest struct { // ListObjectsRequest represents a request to list objects in a bucket type ListObjectsRequest struct { - Bucket string `json:"bucket" validate:"required"` - Prefix string `json:"prefix,omitempty"` - MaxKeys int `json:"max_keys,omitempty"` - Marker string `json:"marker,omitempty"` + Bucket string `json:"bucket" validate:"required"` + Prefix string `json:"prefix,omitempty"` + MaxKeys int `json:"max_keys,omitempty"` + Marker string `json:"marker,omitempty"` } // UploadObjectRequest represents metadata for an object upload @@ -51,6 +57,6 @@ type DeleteUserRequest struct { // UpdateUserRequest represents a request to update user permissions type UpdateUserRequest struct { - AccessKey string `json:"access_key" validate:"required"` - Permissions []string `json:"permissions,omitempty"` + Status *string `json:"status,omitempty"` // "active" or "inactive" + Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string } diff --git a/internal/models/responses.go b/internal/models/responses.go index 7e9475a..3a15262 100644 --- a/internal/models/responses.go +++ b/internal/models/responses.go @@ -2,6 +2,32 @@ package models import "time" +// DashboardMetrics represents aggregated metrics for the dashboard +type DashboardMetrics struct { + TotalSize int64 `json:"totalSize"` + ObjectCount int64 `json:"objectCount"` + BucketCount int `json:"bucketCount"` + UsageByBucket []BucketUsage `json:"usageByBucket"` + RequestMetrics RequestMetrics `json:"requestMetrics"` +} + +// BucketUsage represents storage usage for a single bucket +type BucketUsage struct { + BucketName string `json:"bucketName"` + Size int64 `json:"size"` + ObjectCount int64 `json:"objectCount"` + Percentage float64 `json:"percentage"` +} + +// RequestMetrics represents API request statistics +type RequestMetrics struct { + GetRequests int64 `json:"getRequests"` + PutRequests int64 `json:"putRequests"` + DeleteRequests int64 `json:"deleteRequests"` + ListRequests int64 `json:"listRequests"` + Period string `json:"period"` +} + // APIResponse is the standard response structure for all API endpoints type APIResponse struct { Success bool `json:"success"` @@ -26,6 +52,8 @@ type HealthResponse struct { type BucketInfo struct { Name string `json:"name"` CreationDate time.Time `json:"creation_date"` + ObjectCount *int64 `json:"object_count,omitempty"` + Size *int64 `json:"size,omitempty"` Region string `json:"region,omitempty"` } @@ -47,11 +75,12 @@ type ObjectInfo struct { // ObjectListResponse represents a list of objects in a bucket type ObjectListResponse struct { - Bucket string `json:"bucket"` - Objects []ObjectInfo `json:"objects"` - Count int `json:"count"` - IsTruncated bool `json:"is_truncated"` - NextMarker string `json:"next_marker,omitempty"` + Bucket string `json:"bucket"` + Objects []ObjectInfo `json:"objects"` + Prefixes []string `json:"prefixes"` + Count int `json:"count"` + IsTruncated bool `json:"is_truncated"` + NextContinuationToken string `json:"next_continuation_token,omitempty"` } // ObjectUploadResponse represents the response after uploading an object @@ -63,6 +92,31 @@ type ObjectUploadResponse struct { ContentType string `json:"content_type"` } +// ObjectUploadMultipleResponse represents the response after uploading multiple objects +type ObjectUploadMultipleResponse struct { + Bucket string `json:"bucket"` + TotalFiles int `json:"total_files"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + SuccessFiles []ObjectUploadResult `json:"success_files"` + FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"` +} + +// ObjectUploadResult represents a successful upload result +type ObjectUploadResult struct { + Key string `json:"key"` + ETag string `json:"etag"` + Size int64 `json:"size"` + ContentType string `json:"content_type,omitempty"` +} + +// ObjectUploadFailedResult represents a failed upload result +type ObjectUploadFailedResult struct { + Key string `json:"key"` + Error string `json:"error"` + ContentType string `json:"content_type,omitempty"` +} + // ObjectDeleteResponse represents the response after deleting an object type ObjectDeleteResponse struct { Bucket string `json:"bucket"` @@ -72,10 +126,44 @@ type ObjectDeleteResponse struct { // UserInfo represents information about a Garage user (key pair) type UserInfo struct { - AccessKey string `json:"access_key"` - Name string `json:"name,omitempty"` - Permissions []string `json:"permissions,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + SecretKey *string `json:"secretKey,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + LastUsed *time.Time `json:"lastUsed,omitempty"` + Status string `json:"status"` // "active" or "inactive" + BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions + Expiration *time.Time `json:"expiration,omitempty"` + Expired bool `json:"expired"` +} + +// BucketPermission represents permissions for a specific bucket +type BucketPermission struct { + BucketID string `json:"bucketId"` + BucketName string `json:"bucketName"` + Read bool `json:"read"` + Write bool `json:"write"` + Owner bool `json:"owner"` +} + +// Permission represents a permission entry for access control (legacy/deprecated) +type Permission struct { + Resource string `json:"resource"` + Actions []string `json:"actions"` + Effect string `json:"effect"` // "Allow" or "Deny" +} + +type PresignedURLResponse struct { + URL string `json:"url"` + ExpiresIn int64 `json:"expires_in"` // in seconds + Bucket string `json:"bucket"` + Key string `json:"key"` +} + +type ObjectDeleteMultipleResponse struct { + Bucket string `json:"bucket"` + Deleted int `json:"deleted"` + Keys []string `json:"keys"` } // UserListResponse represents a list of users/keys @@ -109,18 +197,18 @@ func ErrorResponse(code, message string) APIResponse { // Common error codes const ( - ErrCodeBadRequest = "BAD_REQUEST" - ErrCodeUnauthorized = "UNAUTHORIZED" - ErrCodeForbidden = "FORBIDDEN" - ErrCodeNotFound = "NOT_FOUND" - ErrCodeConflict = "CONFLICT" - ErrCodeInternalError = "INTERNAL_ERROR" - ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS" - ErrCodeBucketNotFound = "BUCKET_NOT_FOUND" - ErrCodeObjectNotFound = "OBJECT_NOT_FOUND" - ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME" - ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY" - ErrCodeUploadFailed = "UPLOAD_FAILED" - ErrCodeDeleteFailed = "DELETE_FAILED" - ErrCodeListFailed = "LIST_FAILED" + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeConflict = "CONFLICT" + ErrCodeInternalError = "INTERNAL_ERROR" + ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS" + ErrCodeBucketNotFound = "BUCKET_NOT_FOUND" + ErrCodeObjectNotFound = "OBJECT_NOT_FOUND" + ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME" + ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY" + ErrCodeUploadFailed = "UPLOAD_FAILED" + ErrCodeDeleteFailed = "DELETE_FAILED" + ErrCodeListFailed = "LIST_FAILED" ) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 12103c9..e47d980 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -5,7 +5,13 @@ import ( "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" "Noooste/garage-ui/internal/middleware" + "github.com/gofiber/fiber/v3" + + // Swagger imports + _ "Noooste/garage-ui/docs" + + "github.com/Noooste/swagger" ) // SetupRoutes configures all API routes @@ -17,6 +23,8 @@ func SetupRoutes( bucketHandler *handlers.BucketHandler, objectHandler *handlers.ObjectHandler, userHandler *handlers.UserHandler, + clusterHandler *handlers.ClusterHandler, + monitoringHandler *handlers.MonitoringHandler, ) { // Apply CORS middleware globally app.Use(middleware.CORSMiddleware(&cfg.CORS)) @@ -25,6 +33,9 @@ func SetupRoutes( app.Get("/health", healthHandler.Check) app.Get("/api/v1/health", healthHandler.Check) + // Swagger documentation endpoint (no auth required) + app.Get("/docs/*", swagger.HandlerDefault) + // API v1 group api := app.Group("/api/v1") @@ -34,23 +45,25 @@ func SetupRoutes( // Bucket routes buckets := api.Group("/buckets") { - buckets.Get("/", bucketHandler.ListBuckets) // List all buckets - buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket - buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info - buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket + buckets.Get("/", bucketHandler.ListBuckets) // List all buckets + buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket + buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info + buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket + buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions } // Object routes objects := api.Group("/buckets/:bucket/objects") { - objects.Get("/", objectHandler.ListObjects) // List objects in bucket - objects.Post("/", objectHandler.UploadObject) // Upload object (multipart) - objects.Delete("/", objectHandler.DeleteMultipleObjects) // Delete multiple objects - objects.Get("/:key", objectHandler.GetObject) // Download object - objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream) - objects.Delete("/:key", objectHandler.DeleteObject) // Delete object - objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata - objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL + objects.Get("/", objectHandler.ListObjects) // List objects in bucket + objects.Post("/", objectHandler.UploadObject) // Upload object (multipart) + objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects + objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects + objects.Get("/:key", objectHandler.GetObject) // Download object + objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream) + objects.Delete("/:key", objectHandler.DeleteObject) // Delete object + objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata + objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL } // User/Key management routes @@ -63,6 +76,24 @@ func SetupRoutes( users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions } + // Cluster management routes + cluster := api.Group("/cluster") + { + cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health + cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status + cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics + cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info + cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics + } + + // Monitoring routes + monitoring := api.Group("/monitoring") + { + monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics + monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health + monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics + } + // OIDC authentication routes (only if OIDC is enabled) if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled { authRoutes := app.Group("/auth") diff --git a/internal/services/garage_admin.go b/internal/services/garage_admin.go new file mode 100644 index 0000000..1e8f44b --- /dev/null +++ b/internal/services/garage_admin.go @@ -0,0 +1,460 @@ +package services + +import ( + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/Noooste/azuretls-client" +) + +// GarageAdminService handles interactions with the Garage Admin API +type GarageAdminService struct { + baseURL string + token string + httpClient *azuretls.Session +} + +// NewGarageAdminService creates a new Garage Admin API service +func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService { + session := azuretls.NewSession() + session.Log() + + return &GarageAdminService{ + baseURL: cfg.AdminEndpoint, + token: cfg.AdminToken, + httpClient: session, + } +} + +// doRequest performs an HTTP request to the Admin API +func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { + return s.httpClient.Do(&azuretls.Request{ + Method: method, + Url: s.baseURL + path, + Body: body, + IgnoreBody: true, // decodeResponse will handle body reading + OrderedHeaders: azuretls.OrderedHeaders{ + {"Authorization", fmt.Sprintf("Bearer %s", s.token)}, + }, + }, ctx) +} + +// decodeResponse decodes a JSON response into the target structure +func decodeResponse(resp *azuretls.Response, target interface{}) error { + defer resp.RawBody.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.RawBody) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if target != nil { + if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// ==================================== +// Access Key Operations +// ==================================== + +// ListKeys returns all access keys in the cluster +func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result []models.ListKeysResponseItem + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result, nil +} + +// CreateKey creates a new API access key +func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetKeyInfo returns information about a specific access key +func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID) + if showSecret { + path += "&showSecretKey=true" + } + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// UpdateKey updates information about an access key +func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DeleteKey deletes an access key from the cluster +func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error { + path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + + return nil +} + +// ImportKey imports an existing API access key +func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Bucket Operations (Admin API) +// ==================================== + +// ListBuckets returns all buckets in the cluster +func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result []models.ListBucketsResponseItem + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result, nil +} + +// GetBucketInfo returns detailed information about a bucket by ID +func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetBucketInfoByAlias returns detailed information about a bucket by its global alias +func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// CreateBucket creates a new bucket via the Admin API +func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// UpdateBucket updates bucket settings +func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DeleteBucket deletes a bucket +func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error { + path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + + return nil +} + +// ==================================== +// Bucket Alias Operations +// ==================================== + +// AddBucketAlias adds an alias to a bucket +func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// RemoveBucketAlias removes an alias from a bucket +func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Permission Operations +// ==================================== + +// AllowBucketKey grants permissions for a key on a bucket +func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DenyBucketKey revokes permissions for a key on a bucket +func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Cluster Operations +// ==================================== + +// GetClusterHealth returns the health status of the cluster +func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterHealth + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetClusterStatus returns the current status of the cluster +func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterStatus + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetClusterStatistics returns global cluster statistics +func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterStatistics + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Node Operations +// ==================================== + +// GetNodeInfo returns information about a specific node +func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.MultiNodeResponse + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetNodeStatistics returns statistics for a specific node +func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.MultiNodeResponse + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Monitoring Operations +// ==================================== + +// HealthCheck checks if the Admin API is reachable +func (s *GarageAdminService) HealthCheck(ctx context.Context) error { + resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("health check returned error: %w", err) + } + + return nil +} + +// GetMetrics returns Prometheus metrics from the Admin API +func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.RawBody.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.RawBody) + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + bodyBytes, err := io.ReadAll(resp.RawBody) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + return string(bodyBytes), nil +} diff --git a/internal/services/s3.go b/internal/services/s3.go index 4b7db31..756e7dd 100644 --- a/internal/services/s3.go +++ b/internal/services/s3.go @@ -8,6 +8,8 @@ import ( "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -16,13 +18,14 @@ import ( // S3Service handles all S3 operations with Garage type S3Service struct { - client *s3.Client - config *config.GarageConfig + client *s3.Client + config *config.GarageConfig + adminService *GarageAdminService } // NewS3Service creates a new S3 service instance -func NewS3Service(cfg *config.GarageConfig) *S3Service { - // Create AWS credentials from Garage config +func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service { + // Create AWS credentials from Garage config (default/fallback credentials) creds := credentials.NewStaticCredentialsProvider( cfg.AccessKey, cfg.SecretKey, @@ -42,11 +45,89 @@ func NewS3Service(cfg *config.GarageConfig) *S3Service { }) return &S3Service{ - client: client, - config: cfg, + client: client, + config: cfg, + adminService: adminService, } } +// getBucketCredentials retrieves credentials for a specific bucket +// It checks the cache first, then queries the Garage Admin API +func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (aws.CredentialsProvider, error) { + cacheKey := fmt.Sprintf("key:%s", bucketName) + cacheData := utils.GlobalCache.Get(cacheKey) + + if cacheData != nil { + return cacheData.(aws.CredentialsProvider), nil + } + + // Get bucket info from Garage Admin API + bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get bucket info: %w", err) + } + + // Find a key with read and write permissions + var accessKeyID, secretAccessKey string + for _, keyInfo := range bucketInfo.Keys { + if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write { + continue + } + + // Get key details with secret + keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true) + if err != nil { + return nil, fmt.Errorf("failed to get key info: %w", err) + } + + if keyDetails.SecretAccessKey != nil { + accessKeyID = keyDetails.AccessKeyID + secretAccessKey = *keyDetails.SecretAccessKey + break + } + } + + if accessKeyID == "" || secretAccessKey == "" { + return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName) + } + + // Create credentials provider + credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "") + + // Cache credentials for 1 hour + utils.GlobalCache.Set(cacheKey, credential, time.Hour) + + return credential, nil +} + +// getS3Client creates an S3 client for a specific bucket with dynamic credentials +func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) { + creds, err := s.getBucketCredentials(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err) + } + + // AWS config + awsConfig := aws.Config{ + Credentials: creds, + Region: s.config.Region, + } + + // Build S3 client with BaseEndpoint for Garage + client := s3.NewFromConfig(awsConfig, func(o *s3.Options) { + o.BaseEndpoint = aws.String(s.config.Endpoint) + o.UsePathStyle = s.config.ForcePathStyle + o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: s.config.Endpoint, + SigningRegion: s.config.Region, + }, nil + }) + }) + + return client, nil +} + // ListBuckets retrieves all buckets from Garage func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) { // Call S3 ListBuckets API @@ -72,13 +153,17 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse // CreateBucket creates a new bucket in Garage func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error { - // Create bucket input + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + input := &s3.CreateBucketInput{ Bucket: aws.String(bucketName), } // Call S3 CreateBucket API - _, err := s.client.CreateBucket(ctx, input) + _, err = client.CreateBucket(ctx, input) if err != nil { return fmt.Errorf("failed to create bucket %s: %w", bucketName, err) } @@ -88,8 +173,13 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error { // DeleteBucket deletes a bucket from Garage func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error { + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + // Call S3 DeleteBucket API - _, err := s.client.DeleteBucket(ctx, &s3.DeleteBucketInput{ + _, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{ Bucket: aws.String(bucketName), }) if err != nil { @@ -99,37 +189,36 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error { return nil } -// BucketExists checks if a bucket exists -func (s *S3Service) BucketExists(ctx context.Context, bucketName string) (bool, error) { - _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{ - Bucket: aws.String(bucketName), - }) +// ListObjects lists objects in a bucket with optional prefix filter and pagination +func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) if err != nil { - // Check if it's a "not found" error - return false, nil + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) } - return true, nil -} -// ListObjects lists objects in a bucket with optional prefix filter -func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int) (*models.ObjectListResponse, error) { // Set default max keys if not specified if maxKeys <= 0 { - maxKeys = 1000 + maxKeys = 100 } // Create list objects input input := &s3.ListObjectsV2Input{ - Bucket: aws.String(bucketName), - MaxKeys: aws.Int32(int32(maxKeys)), + Bucket: aws.String(bucketName), + Delimiter: aws.String("/"), + MaxKeys: aws.Int32(int32(maxKeys)), } if prefix != "" { input.Prefix = aws.String(prefix) } + if continuationToken != "" { + input.ContinuationToken = aws.String(continuationToken) + } + // Call S3 ListObjectsV2 API - result, err := s.client.ListObjectsV2(ctx, input) + result, err := client.ListObjectsV2(ctx, input) if err != nil { return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) } @@ -146,17 +235,30 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, }) } + // Extract common prefixes (folders/directories) + prefixes := make([]string, 0, len(result.CommonPrefixes)) + for _, p := range result.CommonPrefixes { + prefixes = append(prefixes, aws.ToString(p.Prefix)) + } + return &models.ObjectListResponse{ - Bucket: bucketName, - Objects: objects, - Count: len(objects), - IsTruncated: aws.ToBool(result.IsTruncated), - NextMarker: aws.ToString(result.NextContinuationToken), + Bucket: bucketName, + Objects: objects, + Prefixes: prefixes, + Count: len(objects), + IsTruncated: aws.ToBool(result.IsTruncated), + NextContinuationToken: aws.ToString(result.NextContinuationToken), }, nil } // UploadObject uploads an object to a bucket func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + // Create put object input input := &s3.PutObjectInput{ Bucket: aws.String(bucketName), @@ -169,13 +271,13 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo } // Call S3 PutObject API - result, err := s.client.PutObject(ctx, input) + result, err := client.PutObject(ctx, input) if err != nil { return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err) } // Get object metadata to return size - headResult, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(key), }) @@ -233,7 +335,13 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er // ObjectExists checks if an object exists in a bucket func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) { - _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + _, err = client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(key), }) @@ -245,7 +353,13 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b // GetObjectMetadata retrieves metadata for an object without downloading it func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) { - result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + result, err := client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(key), }) @@ -268,6 +382,12 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string return nil } + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + // Create delete objects for batch deletion objects := make([]types.ObjectIdentifier, len(keys)) for i, key := range keys { @@ -277,7 +397,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string } // Call S3 DeleteObjects API (batch delete) - _, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + _, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: aws.String(bucketName), Delete: &types.Delete{ Objects: objects, @@ -294,8 +414,14 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string // GetPresignedURL generates a pre-signed URL for temporary access to an object // This is useful for sharing files without exposing credentials func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + // Create presign client - presignClient := s3.NewPresignClient(s.client) + presignClient := s3.NewPresignClient(client) // Generate presigned GET request presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ @@ -310,3 +436,108 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, return presignResult.URL, nil } + +// UploadResult represents the result of a single file upload +type UploadResult struct { + Key string + Success bool + Error error + ETag string + Size int64 + ContentType string +} + +// UploadMultipleObjects uploads multiple objects to a bucket +// It handles uploads in batches to respect any S3/Garage limits +// Returns results for each file, including both successes and failures +func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string +}) []UploadResult { + results := make([]UploadResult, len(files)) + + // Get bucket-specific S3 client once for all uploads + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + // If we can't get the client, all uploads fail + for i := range files { + results[i] = UploadResult{ + Key: files[i].Key, + Success: false, + Error: fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err), + } + } + return results + } + + // Upload each file + for i, file := range files { + // Create put object input + input := &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(file.Key), + Body: file.Body, + } + + if file.ContentType != "" { + input.ContentType = aws.String(file.ContentType) + } + + // Attempt upload + result, err := client.PutObject(ctx, input) + if err != nil { + results[i] = UploadResult{ + Key: file.Key, + Success: false, + Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err), + ContentType: file.ContentType, + } + continue + } + + // Get object metadata to return size + headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(file.Key), + }) + + var size int64 + if err == nil { + size = aws.ToInt64(headResult.ContentLength) + } + + results[i] = UploadResult{ + Key: file.Key, + Success: true, + Error: nil, + ETag: aws.ToString(result.ETag), + Size: size, + ContentType: file.ContentType, + } + } + + return results +} + +// BucketStatistics holds statistical information about a bucket +type BucketStatistics struct { + ObjectCount int64 + TotalSize int64 +} + +// GetBucketStatistics retrieves bucket statistics from Garage Admin API +// This is much more efficient than iterating through all objects +func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) { + // Get bucket info from Garage Admin API which includes object count and size + bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err) + } + + // Return statistics from Admin API + return &BucketStatistics{ + ObjectCount: bucketInfo.Objects, + TotalSize: bucketInfo.Bytes, + }, nil +} diff --git a/cmd/api/main.go b/main.go similarity index 62% rename from cmd/api/main.go rename to main.go index 0dac49a..cb2352f 100644 --- a/cmd/api/main.go +++ b/main.go @@ -13,11 +13,51 @@ import ( "Noooste/garage-ui/internal/handlers" "Noooste/garage-ui/internal/routes" "Noooste/garage-ui/internal/services" + "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" ) +// @title Garage UI API +// @version 0.1.0 +// @description REST API for managing Garage distributed object storage system +// @description This API provides endpoints for managing buckets, objects, users, and cluster operations. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.email support@garage-ui.io + +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT + +// @host localhost:8080 +// @BasePath / +// @schemes http https + +// @tag.name Health +// @tag.description Health check endpoints + +// @tag.name Buckets +// @tag.description Bucket management operations + +// @tag.name Objects +// @tag.description Object storage and retrieval operations + +// @tag.name Users +// @tag.description User and access key management + +// @tag.name Cluster +// @tag.description Cluster status and node management + +// @tag.name Monitoring +// @tag.description Monitoring and metrics endpoints + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. + const version = "0.1.0" func main() { @@ -35,8 +75,11 @@ func main() { log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment) // Initialize services + log.Println("Initializing Garage Admin service...") + adminService := services.NewGarageAdminService(&cfg.Garage) + log.Println("Initializing S3 service...") - s3Service := services.NewS3Service(&cfg.Garage) + s3Service := services.NewS3Service(&cfg.Garage, adminService) log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode) authService, err := auth.NewAuthService(&cfg.Auth) @@ -46,16 +89,18 @@ func main() { // Initialize handlers healthHandler := handlers.NewHealthHandler(version) - bucketHandler := handlers.NewBucketHandler(s3Service) + bucketHandler := handlers.NewBucketHandler(adminService, s3Service) objectHandler := handlers.NewObjectHandler(s3Service) - userHandler := handlers.NewUserHandler() + userHandler := handlers.NewUserHandler(adminService) + clusterHandler := handlers.NewClusterHandler(adminService) + monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service) // Create Fiber app with configuration app := fiber.New(fiber.Config{ - AppName: "Garage UI Backend v" + version, - DisableStartupMessage: false, - EnablePrintRoutes: cfg.IsDevelopment(), - ErrorHandler: customErrorHandler, + AppName: "Garage UI Backend v" + version, + //DisableStartupMessage: false, + //EnablePrintRoutes: cfg.IsDevelopment(), + ErrorHandler: customErrorHandler, }) // Apply global middleware @@ -74,6 +119,8 @@ func main() { bucketHandler, objectHandler, userHandler, + clusterHandler, + monitoringHandler, ) // Start server in a goroutine diff --git a/pkg/utils/cache.go b/pkg/utils/cache.go new file mode 100644 index 0000000..5413ea0 --- /dev/null +++ b/pkg/utils/cache.go @@ -0,0 +1,95 @@ +package utils + +import ( + "sync" + "time" +) + +// CacheItem represents a cached item with expiration +type CacheItem struct { + Value interface{} + Expiration time.Time +} + +// Cache represents a simple in-memory cache with expiration +type Cache struct { + mu sync.RWMutex + items map[string]CacheItem +} + +// NewCache creates a new cache instance +func NewCache() *Cache { + c := &Cache{ + items: make(map[string]CacheItem), + } + + // Start cleanup goroutine + go c.cleanupExpired() + + return c +} + +// Get retrieves a value from the cache +func (c *Cache) Get(key string) interface{} { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists { + return nil + } + + // Check if item has expired + if time.Now().After(item.Expiration) { + return nil + } + + return item.Value +} + +// Set stores a value in the cache with an expiration duration +func (c *Cache) Set(key string, value interface{}, duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = CacheItem{ + Value: value, + Expiration: time.Now().Add(duration), + } +} + +// Delete removes a value from the cache +func (c *Cache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) +} + +// Clear removes all items from the cache +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]CacheItem) +} + +// cleanupExpired periodically removes expired items +func (c *Cache) cleanupExpired() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + for key, item := range c.items { + if now.After(item.Expiration) { + delete(c.items, key) + } + } + c.mu.Unlock() + } +} + +// Global cache instance +var GlobalCache = NewCache()