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:
2025-07-22 16:29:53 +02:00
commit bb44b143ec
43 changed files with 6631 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box } from '@mui/material';
import useAuthStore from './store/authStore';
import ProtectedRoute from './components/Auth/ProtectedRoute';
import Layout from './components/Layout/Layout';
import Login from './components/Auth/Login';
import Dashboard from './components/Dashboard/Dashboard';
import Buckets from './components/Buckets/Buckets';
import Users from './components/Users/Users';
import Policies from './components/Policies/Policies';
import Reports from './components/Reports/Reports';
function App() {
const { checkAuth, isAuthenticated } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Routes>
<Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <Login />
} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/buckets" element={<Buckets />} />
<Route path="/users" element={<Users />} />
<Route path="/policies" element={<Policies />} />
<Route path="/reports" element={<Reports />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Box>
);
}
export default App;
+170
View File
@@ -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;
+219
View File
@@ -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;
+221
View File
@@ -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;
+353
View File
@@ -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;
+17
View File
@@ -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;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import App from './App';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: 'Roboto, Arial, sans-serif',
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
},
},
},
},
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);
+59
View File
@@ -0,0 +1,59 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
// Create axios instance
const api: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_URL || '/api',
timeout: 30000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
api.interceptors.request.use(
(config) => {
// You can add auth token here if needed
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError) => {
if (error.response?.status === 401) {
// Redirect to login on 401
window.location.href = '/login';
localStorage.removeItem('token');
} else if (error.response?.status === 403) {
// Handle forbidden access
console.error('Access forbidden:', error.response.data);
}
return Promise.reject(error);
}
);
export default api;
// API Error handler
export const handleApiError = (error: any): string => {
if (error.response?.data?.message) {
return error.response.data.message;
} else if (error.response?.data?.error) {
return error.response.data.error;
} else if (error.message) {
return error.message;
}
return 'An unexpected error occurred';
};
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import api from '../services/api';
interface User {
role: string;
loginTime: string;
}
interface AuthState {
isAuthenticated: boolean;
user: User | null;
loading: boolean;
login: (password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isAuthenticated: false,
user: null,
loading: false,
login: async (password: string) => {
set({ loading: true });
try {
const response = await api.post('/auth/login', { password });
const { token, role, loginTime } = response.data;
// Store token if needed
if (token) {
localStorage.setItem('token', token);
}
set({
isAuthenticated: true,
user: { role, loginTime },
loading: false,
});
} catch (error) {
set({ loading: false });
throw error;
}
},
logout: async () => {
try {
await api.post('/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
localStorage.removeItem('token');
set({
isAuthenticated: false,
user: null,
});
}
},
checkAuth: async () => {
try {
const response = await api.get('/auth/status');
if (response.data.authenticated) {
set({
isAuthenticated: true,
user: {
role: response.data.role,
loginTime: response.data.loginTime,
},
});
} else {
set({
isAuthenticated: false,
user: null,
});
}
} catch (error) {
set({
isAuthenticated: false,
user: null,
});
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ isAuthenticated: state.isAuthenticated }),
}
)
);
export default useAuthStore;