Initial commit: MinIO WebUI - Complete implementation
- Backend: Express.js API with MinIO CLI integration - Frontend: React with Material-UI for non-technical users - Features: Bucket management, user creation, storage monitoring - Security: JWT auth, IP filtering, encrypted passwords - Docker support for easy deployment - Automated weekly storage reports - Setup and deployment scripts included
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Container,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
LockOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
import { handleApiError } from '../../services/api';
|
||||
|
||||
interface LoginForm {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, loading } = useAuthStore();
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>();
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
try {
|
||||
setError('');
|
||||
await login(data.password);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<LockOutlined sx={{ color: 'white', fontSize: 30 }} />
|
||||
</Box>
|
||||
<Typography component="h1" variant="h5">
|
||||
MinIO WebUI
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Sign in to continue
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<TextField
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Admin Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
error={!!errors.password}
|
||||
helperText={errors.password?.message}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Secure access for administrators only
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
|
||||
const ProtectedRoute: React.FC = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Storage as StorageIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { format } from 'date-fns';
|
||||
import api, { handleApiError } from '../../services/api';
|
||||
import CreateBucketDialog from './CreateBucketDialog';
|
||||
|
||||
interface Bucket {
|
||||
key: string;
|
||||
lastModified?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface BucketSize {
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
objects: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
const Buckets: React.FC = () => {
|
||||
const [buckets, setBuckets] = useState<BucketSize[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedBucket, setSelectedBucket] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadBuckets();
|
||||
}, []);
|
||||
|
||||
const loadBuckets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const response = await api.get('/buckets/sizes');
|
||||
setBuckets(response.data.buckets);
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedBucket) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/buckets/${selectedBucket}`);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedBucket(null);
|
||||
loadBuckets();
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (bucketName: string) => {
|
||||
setSelectedBucket(bucketName);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (dateString === 'No files') return dateString;
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM dd, yyyy HH:mm');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Buckets</Typography>
|
||||
<Box>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton onClick={loadBuckets} sx={{ mr: 1 }}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
Create Bucket
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<LinearProgress />
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bucket Name</TableCell>
|
||||
<TableCell align="right">Size</TableCell>
|
||||
<TableCell align="right">Objects</TableCell>
|
||||
<TableCell>Last Modified</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{buckets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Box sx={{ py: 3 }}>
|
||||
<StorageIcon sx={{ fontSize: 48, color: 'text.secondary' }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
No buckets found. Create your first bucket to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
buckets.map((bucket) => (
|
||||
<TableRow key={bucket.name}>
|
||||
<TableCell>
|
||||
<Typography variant="body1">{bucket.name}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={bucket.sizeFormatted}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{bucket.objects}</TableCell>
|
||||
<TableCell>{formatDate(bucket.lastModified)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Delete bucket">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => openDeleteDialog(bucket.name)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<CreateBucketDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
setCreateDialogOpen(false);
|
||||
loadBuckets();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Delete Bucket</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete the bucket "{selectedBucket}"?
|
||||
This action cannot be undone. The bucket must be empty to be deleted.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Buckets;
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
TextField,
|
||||
Button,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Box,
|
||||
Typography,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
ContentCopy,
|
||||
} from '@mui/icons-material';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import api, { handleApiError } from '../../services/api';
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
bucketName: string;
|
||||
createUser: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const schema = yup.object({
|
||||
bucketName: yup
|
||||
.string()
|
||||
.required('Bucket name is required')
|
||||
.matches(
|
||||
/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/,
|
||||
'Bucket name must be lowercase, 3-63 characters, start and end with letter/number'
|
||||
)
|
||||
.min(3, 'Minimum 3 characters')
|
||||
.max(63, 'Maximum 63 characters'),
|
||||
createUser: yup.boolean(),
|
||||
username: yup
|
||||
.string()
|
||||
.when('createUser', {
|
||||
is: true,
|
||||
then: (schema) =>
|
||||
schema
|
||||
.required('Username is required')
|
||||
.matches(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
'Username can only contain letters, numbers, hyphens, and underscores'
|
||||
)
|
||||
.min(3, 'Minimum 3 characters')
|
||||
.max(32, 'Maximum 32 characters'),
|
||||
}),
|
||||
password: yup
|
||||
.string()
|
||||
.when('createUser', {
|
||||
is: true,
|
||||
then: (schema) =>
|
||||
schema
|
||||
.required('Password is required')
|
||||
.min(8, 'Minimum 8 characters')
|
||||
.matches(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
'Password must contain uppercase, lowercase, and number'
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const CreateBucketDialog: React.FC<CreateBucketDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
createUser: true,
|
||||
},
|
||||
});
|
||||
|
||||
const createUser = watch('createUser');
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
if (data.createUser) {
|
||||
// Create bucket with user (like the script)
|
||||
await api.post('/buckets/with-user', {
|
||||
bucketName: data.bucketName,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
setCredentials({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
} else {
|
||||
// Create bucket only
|
||||
await api.post('/buckets', {
|
||||
bucketName: data.bucketName,
|
||||
});
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setActiveStep(2);
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setActiveStep(0);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setCredentials(null);
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
handleClose();
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const steps = createUser
|
||||
? ['Bucket Details', 'User Credentials', 'Complete']
|
||||
: ['Bucket Details', 'Complete'];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create New Bucket</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 3 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{activeStep === 0 && (
|
||||
<>
|
||||
<TextField
|
||||
{...register('bucketName')}
|
||||
label="Bucket Name"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.bucketName}
|
||||
helperText={
|
||||
errors.bucketName?.message || 'e.g., alice-bucket'
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox {...register('createUser')} defaultChecked />
|
||||
}
|
||||
label="Create user with full access to this bucket"
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||
<Button onClick={handleClose} sx={{ mr: 1 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setActiveStep(1)}
|
||||
disabled={!!errors.bucketName}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && createUser && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create a user with full access to the bucket. The user will be
|
||||
able to read, write, and delete objects in this bucket.
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
{...register('username')}
|
||||
label="Username"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('password')}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
error={!!errors.password}
|
||||
helperText={
|
||||
errors.password?.message ||
|
||||
'Min 8 chars with uppercase, lowercase, and number'
|
||||
}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button onClick={() => setActiveStep(0)}>Back</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Create'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && !createUser && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button onClick={() => setActiveStep(0)}>Back</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Create Bucket'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeStep === 2 && success && (
|
||||
<>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Bucket created successfully!
|
||||
</Alert>
|
||||
|
||||
{credentials && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
User Credentials
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Save these credentials securely. They won't be shown again.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Username:</strong> {credentials.username}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(credentials.username)}
|
||||
>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Password:</strong> {credentials.password}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(credentials.password)}
|
||||
>
|
||||
<ContentCopy fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||
<Button variant="contained" onClick={handleSuccess}>
|
||||
Done
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBucketDialog;
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
People as PeopleIcon,
|
||||
Folder as FolderIcon,
|
||||
Speed as SpeedIcon,
|
||||
} from '@mui/icons-material';
|
||||
import api, { handleApiError } from '../../services/api';
|
||||
|
||||
interface DashboardStats {
|
||||
totalBuckets: number;
|
||||
totalUsers: number;
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: `${color}.light`,
|
||||
color: `${color}.main`,
|
||||
mr: 2,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const [bucketsRes, usersRes, sizesRes] = await Promise.all([
|
||||
api.get('/buckets'),
|
||||
api.get('/users'),
|
||||
api.get('/buckets/sizes'),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
totalBuckets: bucketsRes.data.count,
|
||||
totalUsers: usersRes.data.count,
|
||||
totalSize: sizesRes.data.totalSize,
|
||||
totalSizeFormatted: sizesRes.data.totalSizeFormatted,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: 4 }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Welcome to MinIO WebUI. Monitor your storage infrastructure at a glance.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard
|
||||
title="Total Buckets"
|
||||
value={stats?.totalBuckets || 0}
|
||||
icon={<FolderIcon />}
|
||||
color="primary"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats?.totalUsers || 0}
|
||||
icon={<PeopleIcon />}
|
||||
color="secondary"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard
|
||||
title="Total Storage"
|
||||
value={stats?.totalSizeFormatted || '0 B'}
|
||||
icon={<StorageIcon />}
|
||||
color="success"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<StatCard
|
||||
title="System Status"
|
||||
value="Online"
|
||||
icon={<SpeedIcon />}
|
||||
color="info"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Quick Actions
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Getting Started
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
1. Create a new bucket from the Buckets page
|
||||
<br />
|
||||
2. Add users and assign them to buckets
|
||||
<br />
|
||||
3. Configure policies for fine-grained access control
|
||||
<br />
|
||||
4. Monitor storage usage with weekly reports
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
System Information
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
MinIO WebUI provides a simple interface for managing your MinIO
|
||||
storage infrastructure. All operations are performed securely
|
||||
through the MinIO CLI.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Storage as StorageIcon,
|
||||
People as PeopleIcon,
|
||||
Policy as PolicyIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Logout as LogoutIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useAuthStore from '../../store/authStore';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
interface NavItem {
|
||||
text: string;
|
||||
icon: React.ReactElement;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
|
||||
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets' },
|
||||
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
|
||||
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies' },
|
||||
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports' },
|
||||
];
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', width: '100%' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: `calc(100% - ${open ? drawerWidth : 0}px)`,
|
||||
ml: `${open ? drawerWidth : 0}px`,
|
||||
transition: (theme) =>
|
||||
theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="toggle drawer"
|
||||
onClick={handleDrawerToggle}
|
||||
edge="start"
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{open ? <ChevronLeftIcon /> : <MenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
MinIO WebUI
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title="Account">
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
aria-controls={Boolean(anchorEl) ? 'account-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={Boolean(anchorEl) ? 'true' : undefined}
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
<AccountCircleIcon />
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
id="account-menu"
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
onClick={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 0,
|
||||
sx: {
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
ml: -0.5,
|
||||
mr: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem disabled>
|
||||
<Typography variant="body2">
|
||||
Logged in as {user?.role || 'Admin'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}}
|
||||
variant="persistent"
|
||||
anchor="left"
|
||||
open={open}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: (theme) => theme.spacing(0, 1),
|
||||
...((theme) => theme.mixins.toolbar),
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
MinIO Manager
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
padding: 3,
|
||||
transition: (theme) =>
|
||||
theme.transitions.create('margin', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
marginLeft: open ? 0 : `-${drawerWidth}px`,
|
||||
mt: 8,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
const Policies: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Policies
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Policy management functionality coming soon...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Policies;
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Email as EmailIcon,
|
||||
Download as DownloadIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { Pie } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Tooltip as ChartTooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import api, { handleApiError } from '../../services/api';
|
||||
|
||||
ChartJS.register(ArcElement, ChartTooltip, Legend);
|
||||
|
||||
interface StorageReport {
|
||||
date: string;
|
||||
summary: {
|
||||
totalBuckets: number;
|
||||
totalUsers: number;
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
};
|
||||
buckets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
objects: number;
|
||||
lastModified: string;
|
||||
}>;
|
||||
users: string[];
|
||||
}
|
||||
|
||||
interface ScheduleInfo {
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
nextRun: string | null;
|
||||
recipients: string | string[];
|
||||
}
|
||||
|
||||
const Reports: React.FC = () => {
|
||||
const [report, setReport] = useState<StorageReport | null>(null);
|
||||
const [scheduleInfo, setScheduleInfo] = useState<ScheduleInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const [reportRes, scheduleRes] = await Promise.all([
|
||||
api.get('/reports/storage'),
|
||||
api.get('/reports/schedule'),
|
||||
]);
|
||||
|
||||
setReport(reportRes.data);
|
||||
setScheduleInfo(scheduleRes.data);
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendReport = async () => {
|
||||
try {
|
||||
await api.post('/reports/generate');
|
||||
setSuccessMessage('Report sent successfully!');
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReport = async (format: 'csv' | 'json') => {
|
||||
try {
|
||||
const response = await api.get(`/reports/storage/export?format=${format}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`minio-storage-report-${report?.date}.${format}`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (err) {
|
||||
setError(handleApiError(err));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: report.buckets.map((b) => b.name),
|
||||
datasets: [
|
||||
{
|
||||
data: report.buckets.map((b) => b.size),
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40',
|
||||
'#FF6384',
|
||||
'#C9CBCF',
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const bucket = report.buckets[context.dataIndex];
|
||||
return `${bucket.name}: ${bucket.sizeFormatted}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Typography variant="h4">Storage Reports</Typography>
|
||||
<Box>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton onClick={loadData} sx={{ mr: 1 }}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EmailIcon />}
|
||||
onClick={sendReport}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Send Report
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadReport('csv')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadReport('json')}
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Total Storage
|
||||
</Typography>
|
||||
<Typography variant="h5">
|
||||
{report.summary.totalSizeFormatted}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Total Buckets
|
||||
</Typography>
|
||||
<Typography variant="h5">
|
||||
{report.summary.totalBuckets}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Total Users
|
||||
</Typography>
|
||||
<Typography variant="h5">{report.summary.totalUsers}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Bucket Name</TableCell>
|
||||
<TableCell align="right">Size</TableCell>
|
||||
<TableCell align="right">Objects</TableCell>
|
||||
<TableCell>Last Modified</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{report.buckets.map((bucket) => (
|
||||
<TableRow key={bucket.name}>
|
||||
<TableCell>{bucket.name}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={bucket.sizeFormatted}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{bucket.objects}</TableCell>
|
||||
<TableCell>{bucket.lastModified}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Storage Distribution
|
||||
</Typography>
|
||||
<Box sx={{ height: 300 }}>
|
||||
{report.buckets.length > 0 ? (
|
||||
<Pie data={chartData} options={chartOptions} />
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mt: 10 }}
|
||||
>
|
||||
No data to display
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<ScheduleIcon sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">Report Schedule</Typography>
|
||||
</Box>
|
||||
{scheduleInfo && (
|
||||
<>
|
||||
<Typography variant="body2" paragraph>
|
||||
<strong>Status:</strong>{' '}
|
||||
<Chip
|
||||
label={scheduleInfo.enabled ? 'Enabled' : 'Disabled'}
|
||||
size="small"
|
||||
color={scheduleInfo.enabled ? 'success' : 'default'}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
<strong>Schedule:</strong> {scheduleInfo.schedule}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
<strong>Recipients:</strong>{' '}
|
||||
{Array.isArray(scheduleInfo.recipients)
|
||||
? scheduleInfo.recipients.join(', ')
|
||||
: scheduleInfo.recipients}
|
||||
</Typography>
|
||||
{scheduleInfo.nextRun && (
|
||||
<Typography variant="body2">
|
||||
<strong>Next Run:</strong>{' '}
|
||||
{new Date(scheduleInfo.nextRun).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSuccessMessage('')}
|
||||
message={successMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Reports;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
const Users: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Users
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
User management functionality coming soon...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
Reference in New Issue
Block a user