mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-29 08:27:08 +00:00
feat: enhance authentication and upload features; add JWT support and upload progress component
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {Buckets} from '@/pages/Buckets';
|
||||
import {Cluster} from '@/pages/Cluster';
|
||||
import {AccessControl} from '@/pages/AccessControl';
|
||||
import {Login} from '@/pages/Login';
|
||||
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
|
||||
import {Toaster} from 'sonner';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
@@ -48,6 +49,7 @@ function App() {
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
<Route path="access" element={<AccessControl />} />
|
||||
</Route>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Lock, LogIn } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import type { AuthConfig } from '@/types/auth';
|
||||
|
||||
interface BasicLoginFormProps {
|
||||
@@ -43,16 +43,15 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
|
||||
<Card className="w-full">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">
|
||||
{showOIDC ? 'Sign in to Garage UI' : 'Admin Login'}
|
||||
Welcome to Garage UI
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{showOIDC ? 'Enter your credentials or use SSO' : 'Enter your credentials to access the dashboard'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -94,11 +93,8 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
|
||||
{showOIDC && (
|
||||
<div className="mt-4">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">Or</span>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -13,9 +13,11 @@ export function OIDCLoginView() {
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<LogIn className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<img
|
||||
src="/garage.png"
|
||||
alt="Garage Logo"
|
||||
className="h-16 w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
|
||||
@@ -6,9 +6,10 @@ import {Header} from '@/components/layout/header';
|
||||
import {ObjectsTable} from './ObjectsTable';
|
||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||
import {UploadProgress} from './UploadProgress';
|
||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
import type {S3Object, UploadTask} from '@/types';
|
||||
|
||||
interface ObjectBrowserViewProps {
|
||||
bucketName: string;
|
||||
@@ -23,6 +24,7 @@ interface ObjectBrowserViewProps {
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
uploadTasks: UploadTask[];
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
@@ -48,6 +50,7 @@ export function ObjectBrowserView({
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
uploadTasks,
|
||||
onDeleteObject,
|
||||
onDeleteMultipleObjects,
|
||||
onCreateDirectory,
|
||||
@@ -233,7 +236,7 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
{showUploadZone && uploadTasks.length === 0 && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
@@ -312,6 +315,9 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploadTasks.length > 0 && <UploadProgress tasks={uploadTasks} />}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
@@ -344,6 +350,7 @@ export function ObjectBrowserView({
|
||||
)}
|
||||
|
||||
<ObjectsTable
|
||||
bucketName={bucketName}
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { ObjectMetadata } from '@/types';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
export function ObjectDetailsView() {
|
||||
const navigate = useNavigate();
|
||||
const { bucketName, '*': encodedObjectKey } = useParams();
|
||||
// Decode the object key from the URL
|
||||
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName || !objectKey) {
|
||||
setError('Bucket name and object key are required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await objectsApi.getMetadata(bucketName, objectKey);
|
||||
setMetadata(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
|
||||
console.error('Failed to fetch object metadata:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, [bucketName, objectKey]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
|
||||
try {
|
||||
const blob = await objectsApi.get(bucketName, objectKey);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = objectKey.split('/').pop() || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!bucketName || !objectKey) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await objectsApi.delete(bucketName, objectKey);
|
||||
toast.success('Object deleted successfully');
|
||||
handleBackNavigation();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Navigate back to the bucket explorer with the appropriate prefix
|
||||
// Extract the folder path from the object key (everything before the last /)
|
||||
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
|
||||
const prefix = folderPath ? `${folderPath}/` : '';
|
||||
|
||||
// Navigate to the bucket view with the correct prefix
|
||||
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading object details...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metadata) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Object Details" />
|
||||
<div className="p-4 sm:p-6">
|
||||
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">{error || 'Object not found'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = objectKey?.split('/').pop() || objectKey || '';
|
||||
const pathParts = objectKey?.split('/').filter(part => part) || [];
|
||||
const parentPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={fileName} />
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
{/* Back Button and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBackNavigation}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Name Header */}
|
||||
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
|
||||
<div className="mt-1">
|
||||
<File className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<h2 className="text-lg font-medium text-foreground break-all">
|
||||
{parentPath && (
|
||||
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
|
||||
)}
|
||||
{fileName}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => copyToClipboard(metadata.key)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Object Details Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatDate(metadata.lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Type</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.contentType || 'application/octet-stream'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{metadata.storageClass || 'Standard'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
|
||||
<div className="text-sm font-medium text-muted-foreground">Size</div>
|
||||
<div className="sm:col-span-2 text-sm text-foreground">
|
||||
{formatBytes(metadata.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Metadata Section */}
|
||||
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/30">
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{Object.entries(metadata.metadata).map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-muted/30">
|
||||
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
|
||||
{key}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Object Preview Section */}
|
||||
<div className="border border-border rounded-lg bg-card">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground">No preview available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -11,13 +12,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {ChevronLeft, ChevronRight, Download, Eye, 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 {formatBytes, formatRelativeTime} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
bucketName: string;
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
@@ -41,6 +42,7 @@ type SortColumn = 'name' | 'size' | 'modified';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export function ObjectsTable({
|
||||
bucketName,
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
@@ -59,6 +61,7 @@ export function ObjectsTable({
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
@@ -117,16 +120,22 @@ export function ObjectsTable({
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
// Reset pagination when path/search changes
|
||||
|
||||
// Do NOT reset pagination - search/sort are client-side operations
|
||||
}, [searchQuery, objects, sortColumn, sortDirection]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
useEffect(() => {
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
}, [currentPath]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
@@ -273,14 +282,17 @@ export function ObjectsTable({
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}
|
||||
className="font-medium cursor-pointer hover:underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
{obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
@@ -349,6 +361,10 @@ export function ObjectsTable({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle, Upload, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { UploadTask } from '@/types';
|
||||
|
||||
interface UploadProgressProps {
|
||||
tasks: UploadTask[];
|
||||
}
|
||||
|
||||
export function UploadProgress({ tasks }: UploadProgressProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length;
|
||||
const errorCount = tasks.filter(t => t.status === 'error').length;
|
||||
const totalCount = tasks.length;
|
||||
const processedCount = completedCount + errorCount;
|
||||
const allDone = processedCount === totalCount;
|
||||
|
||||
// Find currently uploading file
|
||||
const currentFile = tasks.find(t => t.status === 'uploading');
|
||||
const currentFileName = currentFile?.key.split('/').pop() || currentFile?.key || 'Processing...';
|
||||
|
||||
// File-based progress plus contribution from current upload
|
||||
const baseProgress = (processedCount / totalCount) * 100;
|
||||
const currentFileContribution = currentFile
|
||||
? (currentFile.progress / 100) * (1 / totalCount) * 100
|
||||
: 0;
|
||||
const overallProgress = Math.min(baseProgress + currentFileContribution, 100);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 shadow-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon and status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0 ${
|
||||
allDone
|
||||
? 'bg-green-500/10 dark:bg-green-500/20'
|
||||
: errorCount > 0
|
||||
? 'bg-yellow-500/10 dark:bg-yellow-500/20'
|
||||
: 'bg-primary/10 dark:bg-primary/20'
|
||||
}`}>
|
||||
{allDone ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500" />
|
||||
) : errorCount > 0 ? (
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 text-primary animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm">
|
||||
{allDone ? 'Upload Complete' : 'Uploading Files'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{processedCount} of {totalCount} files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
allDone ? 'text-green-600 dark:text-green-500' : 'text-primary'
|
||||
}`}>
|
||||
{Math.round(overallProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar with gradient */}
|
||||
<div className="space-y-2">
|
||||
{!allDone && currentFile && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Upload className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="truncate flex-1" title={currentFileName}>
|
||||
{currentFileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-secondary rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-out relative bg-green-500 dark:bg-green-600`}
|
||||
style={{ width: `${overallProgress}%` }}
|
||||
>
|
||||
{/* Animated shimmer effect */}
|
||||
{!allDone && overallProgress > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 dark:via-white/25 to-transparent animate-shimmer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error indicator with icon */}
|
||||
{errorCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-red-500/10 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded-md px-3 py-2 border border-red-200 dark:border-red-900/50">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
{errorCount} file{errorCount > 1 ? 's' : ''} failed to upload
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{allDone && errorCount === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs bg-green-500/10 dark:bg-green-500/20 text-green-700 dark:text-green-400 rounded-md px-3 py-2 border border-green-200 dark:border-green-900/50">
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>All files uploaded successfully</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
|
||||
import type {BucketUsage} from '@/types';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
|
||||
|
||||
interface BucketUsageChartProps {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import type {ClusterHealth} from '@/types';
|
||||
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
|
||||
|
||||
interface ClusterHealthChartProps {
|
||||
data: ClusterHealth;
|
||||
}
|
||||
|
||||
export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db';
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
metric: 'Nodes',
|
||||
healthy: data.storageNodesUp,
|
||||
unhealthy: data.storageNodes - data.storageNodesUp,
|
||||
},
|
||||
{
|
||||
metric: 'Partitions',
|
||||
healthy: data.partitionsAllOk,
|
||||
unhealthy: data.partitions - data.partitionsAllOk,
|
||||
},
|
||||
{
|
||||
metric: 'Connected',
|
||||
healthy: data.connectedNodes,
|
||||
unhealthy: data.knownNodes - data.connectedNodes,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="metric" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="healthy" stackId="a" fill={colors.green} name="Healthy" />
|
||||
<Bar dataKey="unhealthy" stackId="a" fill={unhealthyColor} name="Unhealthy" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import type { RequestMetrics } from '@/types';
|
||||
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
|
||||
|
||||
interface RequestMetricsChartProps {
|
||||
data: RequestMetrics;
|
||||
}
|
||||
|
||||
export function RequestMetricsChart({ data }: RequestMetricsChartProps) {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = isDark ? grafanaColors.dark : grafanaColors.light;
|
||||
const textColor = getTextColor(isDark);
|
||||
const gridColor = getGridColor(isDark);
|
||||
const tooltipStyle = getTooltipStyle(isDark);
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
name: 'Requests (24h)',
|
||||
GET: data.getRequests,
|
||||
PUT: data.putRequests,
|
||||
DELETE: data.deleteRequests,
|
||||
LIST: data.listRequests,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} />
|
||||
<XAxis dataKey="name" stroke={textColor} />
|
||||
<YAxis stroke={textColor} />
|
||||
<Tooltip
|
||||
formatter={(value) => (value as number).toLocaleString()}
|
||||
contentStyle={tooltipStyle as React.CSSProperties}
|
||||
labelStyle={{ color: textColor }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: textColor }} />
|
||||
<Bar dataKey="GET" stackId="a" fill={colors.blue} />
|
||||
<Bar dataKey="PUT" stackId="a" fill={colors.green} />
|
||||
<Bar dataKey="DELETE" stackId="a" fill={colors.red} />
|
||||
<Bar dataKey="LIST" stackId="a" fill={colors.orange} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useDashboardData } from './useApi';
|
||||
export { useBuckets } from './useBuckets';
|
||||
export { useDashboardData, useBuckets } from './useApi';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object } from '@/types';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
||||
@@ -14,6 +14,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
@@ -57,41 +58,103 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
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);
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
folders.add(parts[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize upload tasks
|
||||
const tasks: UploadTask[] = files.map((file, index) => {
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
return {
|
||||
id: `${Date.now()}-${index}`,
|
||||
file,
|
||||
key,
|
||||
bucket: bucketName,
|
||||
progress: 0,
|
||||
status: 'pending' as const,
|
||||
};
|
||||
});
|
||||
|
||||
setUploadTasks(tasks);
|
||||
|
||||
// Upload files with progress tracking and error handling
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Upload files one by one
|
||||
const concurrency = 1;
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
|
||||
|
||||
const batchPromises = batch.map(async (task) => {
|
||||
try {
|
||||
// Update task status to uploading
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, progress } : t
|
||||
));
|
||||
});
|
||||
|
||||
// Update task status to completed
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Update task status to error but continue with other uploads
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
uploadPromises.push(...batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Show summary toast
|
||||
if (errorCount === 0) {
|
||||
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})`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''} from ${folders.size} folder${folders.size > 1 ? 's' : ''} (${folderNames})`);
|
||||
} else {
|
||||
toast.success(`Successfully uploaded ${files.length} file${files.length > 1 ? 's' : ''}`);
|
||||
toast.success(`Successfully uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return false;
|
||||
} else if (successCount > 0) {
|
||||
toast.warning(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}, ${errorCount} failed`);
|
||||
} else {
|
||||
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
// Clear upload tasks after a delay
|
||||
setTimeout(() => {
|
||||
setUploadTasks([]);
|
||||
}, 3000);
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return successCount > 0;
|
||||
}, [bucketName, currentPath, currentContinuationToken, fetchObjects]);
|
||||
|
||||
const deleteObject = useCallback(async (key: string) => {
|
||||
@@ -160,6 +223,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
setItemsPerPage,
|
||||
fetchObjects,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useBuckets() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchBuckets = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await bucketsApi.list();
|
||||
setBuckets(data);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch buckets:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets();
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const createBucket = useCallback(async (name: string, region?: string) => {
|
||||
try {
|
||||
await bucketsApi.create(name, region);
|
||||
toast.success(`Bucket "${name}" created successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Create bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const deleteBucket = useCallback(async (name: string) => {
|
||||
try {
|
||||
await bucketsApi.delete(name);
|
||||
toast.success(`Bucket "${name}" deleted successfully`);
|
||||
await fetchBuckets();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete bucket error:', error);
|
||||
return false;
|
||||
}
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const grantPermission = useCallback(async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await bucketsApi.grantPermission(bucketName, accessKeyId, permissions);
|
||||
toast.success('Permissions granted successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Grant permission error:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
buckets,
|
||||
isLoading,
|
||||
error,
|
||||
fetchBuckets,
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
grantPermission,
|
||||
};
|
||||
}
|
||||
+28
-6
@@ -17,6 +17,12 @@ import type {
|
||||
} from '@/types';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
// Helper function to encode object keys for URLs
|
||||
// Encodes the entire key including slashes to ensure proper handling of special characters
|
||||
const encodeObjectKey = (key: string): string => {
|
||||
return encodeURIComponent(key);
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
@@ -207,6 +213,7 @@ export const objectsApi = {
|
||||
size: obj.size,
|
||||
lastModified: obj.last_modified,
|
||||
etag: obj.etag,
|
||||
contentType: obj.content_type,
|
||||
storageClass: obj.storage_class,
|
||||
isFolder: false,
|
||||
})) || [];
|
||||
@@ -229,23 +236,38 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMetadata: async (bucket: string, key: string): Promise<ObjectMetadata> => {
|
||||
const response = await api.head(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
return response.data.data;
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/metadata`);
|
||||
const data = response.data.data;
|
||||
return {
|
||||
key: data.key,
|
||||
size: data.size,
|
||||
lastModified: data.last_modified,
|
||||
contentType: data.content_type,
|
||||
etag: data.etag,
|
||||
storageClass: data.storage_class,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
},
|
||||
|
||||
upload: async (bucket: string, key: string, file: File): Promise<void> => {
|
||||
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
|
||||
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' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -262,7 +284,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
delete: async (bucket: string, key: string): Promise<void> => {
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}`);
|
||||
await api.delete(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`);
|
||||
},
|
||||
|
||||
deleteMultiple: async (bucket: string, keys: string[], prefix?: string): Promise<void> => {
|
||||
@@ -271,7 +293,7 @@ export const objectsApi = {
|
||||
},
|
||||
|
||||
getPresignedUrl: async (bucket: string, key: string, expiresIn: number = 3600): Promise<string> => {
|
||||
const response = await api.post(`/v1/buckets/${bucket}/objects/${encodeURIComponent(key)}/presign`, {}, {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/presign`, {
|
||||
params: { expires_in: expiresIn }
|
||||
});
|
||||
return response.data.data.url;
|
||||
|
||||
@@ -98,3 +98,18 @@ export function formatRelativeTime(date: Date): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable size
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
|
||||
@@ -58,12 +58,6 @@ export function AccessControl() {
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
// Key settings state (activation/expiration)
|
||||
// const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
// const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
// const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
// const [expirationDate, setExpirationDate] = useState<string>('');
|
||||
// const [neverExpires, setNeverExpires] = useState(true);
|
||||
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsKey, setSettingsKey] = useState<AccessKey | null>(null);
|
||||
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
|
||||
@@ -75,6 +69,14 @@ export function AccessControl() {
|
||||
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
|
||||
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
|
||||
|
||||
// Key details dialog state
|
||||
const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false);
|
||||
const [viewingKey, setViewingKey] = useState<AccessKey | null>(null);
|
||||
const [detailsSecretKey, setDetailsSecretKey] = useState<string>('');
|
||||
const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false);
|
||||
const [copiedAccessKeyId, setCopiedAccessKeyId] = useState(false);
|
||||
const [copiedSecretKey, setCopiedSecretKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
@@ -341,6 +343,25 @@ export function AccessControl() {
|
||||
return perms.join(', ') || 'None';
|
||||
};
|
||||
|
||||
const handleRowClick = async (key: AccessKey) => {
|
||||
setViewingKey(key);
|
||||
setKeyDetailsDialogOpen(true);
|
||||
setDetailsSecretKey('');
|
||||
setIsLoadingDetailsSecretKey(true);
|
||||
setCopiedAccessKeyId(false);
|
||||
setCopiedSecretKey(false);
|
||||
|
||||
// Fetch the secret key immediately
|
||||
try {
|
||||
const secretKey = await accessApi.getSecretKey(key.accessKeyId);
|
||||
setDetailsSecretKey(secretKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch secret key:', error);
|
||||
} finally {
|
||||
setIsLoadingDetailsSecretKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
@@ -418,7 +439,6 @@ export function AccessControl() {
|
||||
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Last Used</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Permissions</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
@@ -426,7 +446,7 @@ export function AccessControl() {
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading API keys...</span>
|
||||
@@ -435,24 +455,36 @@ export function AccessControl() {
|
||||
</TableRow>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<TableRow key={key.accessKeyId}>
|
||||
<TableRow
|
||||
key={key.accessKeyId}
|
||||
onClick={() => handleRowClick(key)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block">
|
||||
<code
|
||||
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
{key.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(key.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
@@ -467,9 +499,6 @@ export function AccessControl() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{key.lastUsed ? formatDate(key.lastUsed) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.permissions.slice(0, 2).map((perm, idx) => (
|
||||
@@ -487,7 +516,7 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon">
|
||||
@@ -578,7 +607,13 @@ export function AccessControl() {
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}}
|
||||
>
|
||||
{newlyCreatedKey.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
@@ -596,7 +631,15 @@ export function AccessControl() {
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (newlyCreatedKey.secretKey) {
|
||||
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{newlyCreatedKey.secretKey}
|
||||
</code>
|
||||
<Button
|
||||
@@ -613,14 +656,14 @@ export function AccessControl() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
|
||||
<div className="border rounded-lg p-4 bg-orange-100 border-orange-300 dark:bg-orange-950/20 dark:border-orange-900">
|
||||
<div className="flex gap-2">
|
||||
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
|
||||
<ShieldX className="h-5 w-5 text-orange-700 dark:text-orange-500 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
<p className="text-sm font-medium text-orange-950 dark:text-orange-200">
|
||||
Important: Save This Key Now
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
<p className="text-xs text-orange-900 dark:text-orange-300">
|
||||
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
|
||||
If you lose it, you'll need to create a new key.
|
||||
</p>
|
||||
@@ -794,7 +837,15 @@ export function AccessControl() {
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm bg-muted px-3 py-2 rounded flex-1">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (selectedKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(selectedKey.accessKeyId);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
@@ -821,7 +872,15 @@ export function AccessControl() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (revealedSecretKey) {
|
||||
navigator.clipboard.writeText(revealedSecretKey);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{revealedSecretKey}
|
||||
</code>
|
||||
<Button
|
||||
@@ -841,19 +900,6 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-900">
|
||||
<div className="flex gap-2">
|
||||
<ShieldX className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Security Warning
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
Keep this secret key secure. Anyone with access to it can perform operations on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setSecretKeyDialogOpen(false)}>
|
||||
@@ -934,26 +980,6 @@ export function AccessControl() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Display */}
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Status:</span>
|
||||
<Badge variant={settingsKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{settingsKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{settingsKey?.expiration && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Current Expiration:</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(settingsKey.expiration)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
|
||||
@@ -966,6 +992,190 @@ export function AccessControl() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Details Dialog */}
|
||||
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage your API key credentials and permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Key Name and Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Key Name</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey?.name}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<div>
|
||||
<Badge variant={viewingKey?.status === 'active' ? 'default' : 'secondary'}>
|
||||
{viewingKey?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Key ID */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Access Key ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{viewingKey?.accessKeyId}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (viewingKey?.accessKeyId) {
|
||||
navigator.clipboard.writeText(viewingKey.accessKeyId);
|
||||
setCopiedAccessKeyId(true);
|
||||
setTimeout(() => setCopiedAccessKeyId(false), 2000);
|
||||
toast.success('Access Key ID copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copiedAccessKeyId ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Access Key */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Secret Access Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoadingDetailsSecretKey ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading secret key...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'•'.repeat(40)}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (detailsSecretKey) {
|
||||
navigator.clipboard.writeText(detailsSecretKey);
|
||||
setCopiedSecretKey(true);
|
||||
setTimeout(() => setCopiedSecretKey(false), 2000);
|
||||
toast.success('Secret Access Key copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={!detailsSecretKey}
|
||||
>
|
||||
{copiedSecretKey ? 'Copied' : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Created</label>
|
||||
<div className="text-sm text-muted-foreground">{viewingKey && formatDate(viewingKey.createdAt)}</div>
|
||||
</div>
|
||||
{viewingKey?.expiration && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Expiration</label>
|
||||
<div className="text-sm text-muted-foreground">{formatDate(viewingKey.expiration)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bucket Permissions */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Bucket Permissions</label>
|
||||
{viewingKey && viewingKey.permissions.length > 0 ? (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{viewingKey.permissions.map((perm, idx) => (
|
||||
<div key={idx} className="p-3 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">{perm.bucketName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatPermissions(perm)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{perm.read && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Read
|
||||
</Badge>
|
||||
)}
|
||||
{perm.write && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Write
|
||||
</Badge>
|
||||
)}
|
||||
{perm.owner && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This key has no bucket permissions yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeyDetailsDialogOpen(false);
|
||||
if (viewingKey) {
|
||||
handleOpenEditPermissions(viewingKey);
|
||||
}
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Edit Permissions
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setKeyDetailsDialogOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets } from '@/hooks/useBuckets';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
@@ -51,7 +51,10 @@ export function Buckets() {
|
||||
}, [searchParams]);
|
||||
|
||||
// Custom hooks
|
||||
const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets();
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
|
||||
const createBucketMutation = useCreateBucket();
|
||||
const deleteBucketMutation = useDeleteBucket();
|
||||
const grantPermissionMutation = useGrantBucketPermission();
|
||||
const {
|
||||
objects,
|
||||
isLoading: objectsLoading,
|
||||
@@ -62,6 +65,7 @@ export function Buckets() {
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
uploadFiles,
|
||||
uploadTasks,
|
||||
deleteObject,
|
||||
deleteMultipleObjects,
|
||||
createDirectory,
|
||||
@@ -145,6 +149,38 @@ export function Buckets() {
|
||||
}
|
||||
};
|
||||
|
||||
// Wrapper functions for mutations to match dialog APIs
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createBucketMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBucket = async (name: string) => {
|
||||
try {
|
||||
await deleteBucketMutation.mutateAsync(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const grantPermission = async (
|
||||
bucketName: string,
|
||||
accessKeyId: string,
|
||||
permissions: { read: boolean; write: boolean; owner: boolean }
|
||||
) => {
|
||||
try {
|
||||
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing a bucket's objects, show the object browser view
|
||||
if (viewingBucket) {
|
||||
return (
|
||||
@@ -161,6 +197,7 @@ export function Buckets() {
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
uploadTasks={uploadTasks}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {garageApi} from '@/lib/api';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatBytes} from '@/lib/file-utils';
|
||||
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
|
||||
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
|
||||
import {useDashboardData} from '@/hooks/useApi';
|
||||
|
||||
@@ -49,10 +49,6 @@ export function Login() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Sign in to Garage UI</h1>
|
||||
<p className="text-muted-foreground mt-2">Enter your credentials to continue</p>
|
||||
</div>
|
||||
<BasicLoginForm showOIDC={true} config={config} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface S3Object {
|
||||
size: number;
|
||||
lastModified: string;
|
||||
etag?: string;
|
||||
contentType?: string;
|
||||
storageClass?: string;
|
||||
isFolder?: boolean;
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export interface ObjectMetadata {
|
||||
lastModified: string;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
storageClass?: string;
|
||||
metadata?: Record<string, string>;
|
||||
versionId?: string;
|
||||
}
|
||||
@@ -62,7 +64,6 @@ export interface AccessKey {
|
||||
name: string;
|
||||
secretKey?: string;
|
||||
createdAt: string;
|
||||
lastUsed?: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: BucketPermission[];
|
||||
expiration?: string;
|
||||
|
||||
Reference in New Issue
Block a user