mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-28 07:57:03 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user