feat: Add Storage Explorer with CLI terminal, alias management, and file browser

- Add CLI terminal for executing mc commands from the web interface
  - Command history with up/down arrow navigation
  - Dark theme with monospace font
  - Auto-scroll output and loading states

- Add alias management page
  - List all MinIO aliases with connection status
  - Add, edit, delete aliases
  - Test connection functionality

- Add file browser for navigating bucket contents
  - Breadcrumb navigation (alias > bucket > path)
  - File/folder table with size, type, last modified
  - Upload files (drag-and-drop support)
  - Download, rename, delete operations
  - Create new folders
  - Multi-select for batch delete

Backend:
- New API routes: /api/terminal, /api/aliases, /api/browser
- Multer middleware for file uploads
- Extended minio.service.js with file operations

Frontend:
- New Explorer component with Aliases/Terminal tabs
- Zustand stores for terminal and explorer state
- i18n translations (English and German)
This commit is contained in:
Paul Nothaft
2026-01-04 22:19:03 +01:00
parent feae9882bc
commit f24ed78c38
28 changed files with 3844 additions and 3 deletions
+2 -1
View File
@@ -33,7 +33,8 @@
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"winston": "^3.11.0", "winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1" "winston-daily-rotate-file": "^4.7.1",
"multer": "^1.4.5-lts.1"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.56.0", "eslint": "^8.56.0",
+254
View File
@@ -0,0 +1,254 @@
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const MinIOService = require('../../services/minio.service');
const { authMiddleware } = require('../../middleware/auth.middleware');
const { logger, logAudit } = require('../../utils/logger');
const { AppError } = require('../../middleware/errorHandler.middleware');
const router = express.Router();
const minioService = new MinIOService();
// Apply auth middleware to all routes
router.use(authMiddleware);
// Validation rules
const aliasValidation = {
aliasName: body('aliasName')
.trim()
.notEmpty()
.withMessage('Alias name is required')
.matches(/^[a-zA-Z0-9_-]+$/)
.withMessage('Alias name can only contain letters, numbers, hyphens, and underscores')
.isLength({ min: 1, max: 32 })
.withMessage('Alias name must be 1-32 characters'),
aliasNameParam: param('name')
.trim()
.notEmpty()
.matches(/^[a-zA-Z0-9_-]+$/),
endpoint: body('endpoint')
.trim()
.notEmpty()
.withMessage('Endpoint is required')
.isURL({ protocols: ['http', 'https'], require_protocol: true })
.withMessage('Invalid endpoint URL'),
accessKey: body('accessKey')
.trim()
.notEmpty()
.withMessage('Access key is required')
.isLength({ min: 3, max: 256 }),
secretKey: body('secretKey')
.trim()
.notEmpty()
.withMessage('Secret key is required')
.isLength({ min: 8, max: 256 }),
};
// Handle validation errors
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation Error',
errors: errors.array(),
});
}
next();
};
/**
* GET /api/aliases
* List all MinIO aliases
*/
router.get('/', async (req, res, next) => {
try {
const aliases = await minioService.listAliases();
// Enrich with connection status (async)
const enrichedAliases = await Promise.all(
aliases.map(async (alias) => {
try {
const testResult = await minioService.testConnection(alias.alias);
return { ...alias, status: testResult.status };
} catch {
return { ...alias, status: 'unknown' };
}
})
);
res.json({
aliases: enrichedAliases,
count: enrichedAliases.length,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/aliases/:name
* Get a specific alias
*/
router.get(
'/:name',
[aliasValidation.aliasNameParam],
handleValidationErrors,
async (req, res, next) => {
try {
const { name } = req.params;
const alias = await minioService.getAlias(name);
// Get connection status
const testResult = await minioService.testConnection(name);
res.json({
...alias,
status: testResult.status,
});
} catch (error) {
next(error);
}
}
);
/**
* POST /api/aliases
* Add a new alias
*/
router.post(
'/',
[
aliasValidation.aliasName,
aliasValidation.endpoint,
aliasValidation.accessKey,
aliasValidation.secretKey,
],
handleValidationErrors,
async (req, res, next) => {
try {
const { aliasName, endpoint, accessKey, secretKey } = req.body;
const result = await minioService.addAlias(aliasName, endpoint, accessKey, secretKey);
logAudit('ALIAS_CREATE', {
userId: req.user.role,
ip: req.ip,
resource: aliasName,
status: 'success',
details: { endpoint },
});
res.status(201).json(result);
} catch (error) {
logAudit('ALIAS_CREATE', {
userId: req.user.role,
ip: req.ip,
resource: req.body.aliasName,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* PUT /api/aliases/:name
* Update an existing alias
*/
router.put(
'/:name',
[
aliasValidation.aliasNameParam,
aliasValidation.endpoint,
aliasValidation.accessKey,
aliasValidation.secretKey,
],
handleValidationErrors,
async (req, res, next) => {
try {
const { name } = req.params;
const { endpoint, accessKey, secretKey } = req.body;
const result = await minioService.updateAlias(name, endpoint, accessKey, secretKey);
logAudit('ALIAS_UPDATE', {
userId: req.user.role,
ip: req.ip,
resource: name,
status: 'success',
details: { endpoint },
});
res.json(result);
} catch (error) {
logAudit('ALIAS_UPDATE', {
userId: req.user.role,
ip: req.ip,
resource: req.params.name,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* DELETE /api/aliases/:name
* Remove an alias
*/
router.delete(
'/:name',
[aliasValidation.aliasNameParam],
handleValidationErrors,
async (req, res, next) => {
try {
const { name } = req.params;
const result = await minioService.removeAlias(name);
logAudit('ALIAS_DELETE', {
userId: req.user.role,
ip: req.ip,
resource: name,
status: 'success',
});
res.json(result);
} catch (error) {
logAudit('ALIAS_DELETE', {
userId: req.user.role,
ip: req.ip,
resource: req.params.name,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* POST /api/aliases/:name/test
* Test connection to an alias
*/
router.post(
'/:name/test',
[aliasValidation.aliasNameParam],
handleValidationErrors,
async (req, res, next) => {
try {
const { name } = req.params;
const result = await minioService.testConnection(name);
res.json(result);
} catch (error) {
next(error);
}
}
);
module.exports = router;
+486
View File
@@ -0,0 +1,486 @@
const express = require('express');
const { body, param, query, validationResult } = require('express-validator');
const fs = require('fs');
const fsPromises = require('fs').promises;
const MinIOService = require('../../services/minio.service');
const { authMiddleware } = require('../../middleware/auth.middleware');
const { upload, cleanupUploadedFiles } = require('../../middleware/upload.middleware');
const { logger, logAudit } = require('../../utils/logger');
const { AppError } = require('../../middleware/errorHandler.middleware');
const router = express.Router();
const minioService = new MinIOService();
// Apply auth middleware to all routes
router.use(authMiddleware);
// Validation rules
const browserValidation = {
alias: param('alias')
.trim()
.notEmpty()
.matches(/^[a-zA-Z0-9_-]+$/),
bucket: param('bucket')
.trim()
.notEmpty()
.matches(/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/),
prefix: query('prefix')
.optional()
.custom((value) => {
if (value && value.includes('..')) {
throw new Error('Invalid prefix');
}
return true;
}),
recursive: query('recursive')
.optional()
.isBoolean()
.toBoolean(),
rename: [
body('source')
.trim()
.notEmpty()
.withMessage('Source path is required'),
body('destination')
.trim()
.notEmpty()
.withMessage('Destination path is required')
.custom((value, { req }) => {
if (value.includes('..') || req.body.source?.includes('..')) {
throw new Error('Path traversal not allowed');
}
return true;
}),
],
folderName: body('folderName')
.trim()
.notEmpty()
.withMessage('Folder name is required')
.matches(/^[a-zA-Z0-9._-]+$/)
.withMessage('Invalid folder name'),
};
// Handle validation errors
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation Error',
errors: errors.array(),
});
}
next();
};
// Path parameter handler for wildcard routes
const getPathFromParams = (req) => {
// For routes like /download/* or /stat/*, the path is in params[0]
return req.params[0] || req.params.path || '';
};
/**
* GET /api/browser/:alias/:bucket
* List objects in a bucket/prefix
*/
router.get(
'/:alias/:bucket',
[browserValidation.alias, browserValidation.bucket, browserValidation.prefix, browserValidation.recursive],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const { prefix, recursive } = req.query;
const result = await minioService.listObjects(alias, bucket, {
prefix: prefix || '',
recursive: recursive || false,
});
res.json(result);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/browser/:alias/:bucket/stat/*
* Get object metadata
*/
router.get(
'/:alias/:bucket/stat/*',
[browserValidation.alias, browserValidation.bucket],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const objectPath = getPathFromParams(req);
if (!objectPath) {
throw new AppError('Object path is required', 400);
}
const result = await minioService.getObjectStat(alias, bucket, objectPath);
res.json(result);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/browser/:alias/:bucket/size/*
* Get size of path (du)
*/
router.get(
'/:alias/:bucket/size/*',
[browserValidation.alias, browserValidation.bucket],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const objectPath = getPathFromParams(req);
const result = await minioService.getPathSize(alias, bucket, objectPath);
res.json(result);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/browser/:alias/:bucket/size
* Get bucket size
*/
router.get(
'/:alias/:bucket/size',
[browserValidation.alias, browserValidation.bucket],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const result = await minioService.getPathSize(alias, bucket, '');
res.json(result);
} catch (error) {
next(error);
}
}
);
/**
* GET /api/browser/:alias/:bucket/download/*
* Download a file
*/
router.get(
'/:alias/:bucket/download/*',
[browserValidation.alias, browserValidation.bucket],
handleValidationErrors,
async (req, res, next) => {
let downloadResult = null;
try {
const { alias, bucket } = req.params;
const objectPath = getPathFromParams(req);
if (!objectPath) {
throw new AppError('Object path is required', 400);
}
// Download to temp file
downloadResult = await minioService.downloadObject(alias, bucket, objectPath);
// Get file stats for Content-Length
const stats = await fsPromises.stat(downloadResult.filePath);
// Set headers
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(downloadResult.fileName)}"`
);
res.setHeader('Content-Length', stats.size);
res.setHeader('Content-Type', 'application/octet-stream');
// Stream file
const fileStream = fs.createReadStream(downloadResult.filePath);
fileStream.on('error', async (error) => {
logger.error('File stream error:', error);
if (downloadResult) {
await downloadResult.cleanup();
}
});
fileStream.on('end', async () => {
if (downloadResult) {
await downloadResult.cleanup();
}
});
// Handle client disconnect
res.on('close', async () => {
if (!res.writableEnded && downloadResult) {
await downloadResult.cleanup();
}
});
logAudit('FILE_DOWNLOAD', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${objectPath}`,
status: 'success',
});
fileStream.pipe(res);
} catch (error) {
if (downloadResult) {
await downloadResult.cleanup();
}
logAudit('FILE_DOWNLOAD', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}/${getPathFromParams(req)}`,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* POST /api/browser/:alias/:bucket/upload
* Upload files
*/
router.post(
'/:alias/:bucket/upload',
[browserValidation.alias, browserValidation.bucket, browserValidation.prefix],
handleValidationErrors,
upload.array('files', 100),
async (req, res, next) => {
const uploadedFiles = req.files || [];
try {
const { alias, bucket } = req.params;
const prefix = req.query.prefix || '';
if (uploadedFiles.length === 0) {
throw new AppError('No files uploaded', 400);
}
const results = {
uploaded: [],
failed: [],
};
// Upload each file
for (const file of uploadedFiles) {
try {
const objectPath = prefix ? `${prefix}${file.originalname}` : file.originalname;
await minioService.uploadObject(alias, bucket, objectPath, file.path);
results.uploaded.push({
name: file.originalname,
key: objectPath,
size: file.size,
});
} catch (error) {
results.failed.push({
name: file.originalname,
error: error.message,
});
}
}
logAudit('FILE_UPLOAD', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${prefix}`,
status: results.failed.length === 0 ? 'success' : 'partial',
details: {
uploaded: results.uploaded.length,
failed: results.failed.length,
},
});
res.status(201).json({
...results,
count: results.uploaded.length,
});
} catch (error) {
logAudit('FILE_UPLOAD', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}`,
status: 'failed',
details: { error: error.message },
});
next(error);
} finally {
// Always cleanup uploaded files
await cleanupUploadedFiles(uploadedFiles);
}
}
);
/**
* DELETE /api/browser/:alias/:bucket/*
* Delete an object
*/
router.delete(
'/:alias/:bucket/*',
[browserValidation.alias, browserValidation.bucket],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const objectPath = getPathFromParams(req);
const recursive = req.query.recursive === 'true';
if (!objectPath) {
throw new AppError('Object path is required', 400);
}
const result = await minioService.deleteObject(alias, bucket, objectPath, recursive);
logAudit('FILE_DELETE', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${objectPath}`,
status: 'success',
details: { recursive },
});
res.json(result);
} catch (error) {
logAudit('FILE_DELETE', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}/${getPathFromParams(req)}`,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* POST /api/browser/:alias/:bucket/rename
* Rename/move an object
*/
router.post(
'/:alias/:bucket/rename',
[browserValidation.alias, browserValidation.bucket, ...browserValidation.rename],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const { source, destination } = req.body;
const result = await minioService.renameObject(alias, bucket, source, destination);
logAudit('FILE_RENAME', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${source}`,
status: 'success',
details: { destination },
});
res.json(result);
} catch (error) {
logAudit('FILE_RENAME', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}/${req.body.source}`,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* POST /api/browser/:alias/:bucket/copy
* Copy an object
*/
router.post(
'/:alias/:bucket/copy',
[browserValidation.alias, browserValidation.bucket, ...browserValidation.rename],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const { source, destination } = req.body;
const result = await minioService.copyObject(alias, bucket, source, destination);
logAudit('FILE_COPY', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${source}`,
status: 'success',
details: { destination },
});
res.json(result);
} catch (error) {
logAudit('FILE_COPY', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}/${req.body.source}`,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
/**
* POST /api/browser/:alias/:bucket/mkdir
* Create a folder
*/
router.post(
'/:alias/:bucket/mkdir',
[browserValidation.alias, browserValidation.bucket, browserValidation.folderName, browserValidation.prefix],
handleValidationErrors,
async (req, res, next) => {
try {
const { alias, bucket } = req.params;
const { folderName } = req.body;
const prefix = req.query.prefix || '';
const folderPath = prefix ? `${prefix}${folderName}` : folderName;
const result = await minioService.createFolder(alias, bucket, folderPath);
logAudit('FOLDER_CREATE', {
userId: req.user.role,
ip: req.ip,
resource: `${alias}/${bucket}/${folderPath}`,
status: 'success',
});
res.status(201).json(result);
} catch (error) {
logAudit('FOLDER_CREATE', {
userId: req.user.role,
ip: req.ip,
resource: `${req.params.alias}/${req.params.bucket}/${req.body.folderName}`,
status: 'failed',
details: { error: error.message },
});
next(error);
}
}
);
module.exports = router;
+82
View File
@@ -0,0 +1,82 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const MinIOService = require('../../services/minio.service');
const { authMiddleware } = require('../../middleware/auth.middleware');
const { logger, logAudit } = require('../../utils/logger');
const router = express.Router();
const minioService = new MinIOService();
// Apply auth middleware to all routes
router.use(authMiddleware);
// Validation rules
const terminalValidation = {
command: body('command')
.trim()
.notEmpty()
.withMessage('Command is required')
.isLength({ max: 4096 })
.withMessage('Command too long (max 4096 characters)'),
timeout: body('timeout')
.optional()
.isInt({ min: 1000, max: 300000 })
.withMessage('Timeout must be between 1000 and 300000 ms'),
};
// Handle validation errors
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation Error',
errors: errors.array(),
});
}
next();
};
/**
* POST /api/terminal/execute
* Execute an mc command and return the output
*/
router.post(
'/execute',
[terminalValidation.command, terminalValidation.timeout],
handleValidationErrors,
async (req, res, next) => {
try {
const { command, timeout } = req.body;
// Execute the command
const result = await minioService.executeRawCommand(command, {
timeout: timeout || 30000,
});
// Audit log all terminal commands
logAudit('TERMINAL_EXECUTE', {
userId: req.user.role,
ip: req.ip,
resource: command.substring(0, 100), // Truncate for log
status: result.success ? 'success' : 'failed',
details: {
exitCode: result.exitCode,
executionTime: result.executionTime,
},
});
res.json(result);
} catch (error) {
logAudit('TERMINAL_EXECUTE', {
userId: req.user.role,
ip: req.ip,
resource: req.body.command?.substring(0, 100),
status: 'error',
details: { error: error.message },
});
next(error);
}
}
);
module.exports = router;
+22
View File
@@ -18,6 +18,9 @@ const bucketRoutes = require('./api/buckets');
const userRoutes = require('./api/users'); const userRoutes = require('./api/users');
const policyRoutes = require('./api/policies'); const policyRoutes = require('./api/policies');
const reportRoutes = require('./api/reports'); const reportRoutes = require('./api/reports');
const terminalRoutes = require('./api/terminal');
const aliasRoutes = require('./api/aliases');
const browserRoutes = require('./api/browser');
// Create Express app // Create Express app
const app = express(); const app = express();
@@ -111,6 +114,22 @@ const authLimiter = rateLimit({
app.use('/api/auth/login', authLimiter); app.use('/api/auth/login', authLimiter);
// Rate limiting for terminal commands (30 per minute)
const terminalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
message: 'Too many commands, please wait.',
});
app.use('/api/terminal', terminalLimiter);
// Rate limiting for browser operations (60 per minute)
const browserLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
message: 'Too many file operations, please slow down.',
});
app.use('/api/browser', browserLimiter);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
@@ -126,6 +145,9 @@ app.use('/api/buckets', bucketRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/policies', policyRoutes); app.use('/api/policies', policyRoutes);
app.use('/api/reports', reportRoutes); app.use('/api/reports', reportRoutes);
app.use('/api/terminal', terminalRoutes);
app.use('/api/aliases', aliasRoutes);
app.use('/api/browser', browserRoutes);
// 404 handler // 404 handler
app.use((req, res) => { app.use((req, res) => {
+129
View File
@@ -0,0 +1,129 @@
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
const os = require('os');
const { logger } = require('../utils/logger');
// Determine upload directory with fallbacks
const getUploadDir = async () => {
const candidates = [
path.join(__dirname, '../../temp/uploads'),
path.join(process.cwd(), 'temp/uploads'),
path.join(os.tmpdir(), 'minio-webui-uploads'),
];
for (const dir of candidates) {
try {
await fs.mkdir(dir, { recursive: true, mode: 0o755 });
// Test write permissions
const testFile = path.join(dir, `.write-test-${Date.now()}`);
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
logger.info(`Using upload directory: ${dir}`);
return dir;
} catch (error) {
logger.debug(`Upload directory ${dir} not usable:`, error.message);
}
}
// Last resort
const fallback = path.join(process.cwd(), '.uploads');
await fs.mkdir(fallback, { recursive: true }).catch(() => {});
return fallback;
};
// Initialize upload directory
let uploadDir = null;
const initUploadDir = async () => {
if (!uploadDir) {
uploadDir = await getUploadDir();
}
return uploadDir;
};
// Configure storage
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
try {
const dir = await initUploadDir();
cb(null, dir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
// Generate unique filename to prevent conflicts
const uniqueSuffix = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
// Sanitize original filename
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${uniqueSuffix}-${sanitizedName}`);
},
});
// File filter - allow all file types for object storage
const fileFilter = (req, file, cb) => {
cb(null, true);
};
// Create multer instance
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 * 1024, // 5GB limit
files: 100, // Max 100 files per request
},
});
// Cleanup helper
const cleanupUploadedFiles = async (files) => {
if (!files) return;
const fileArray = Array.isArray(files) ? files : [files];
for (const file of fileArray) {
try {
if (file.path) {
await fs.unlink(file.path);
logger.debug(`Cleaned up uploaded file: ${file.path}`);
}
} catch (error) {
logger.debug(`Failed to cleanup file ${file.path}:`, error.message);
}
}
};
// Cleanup old uploads (files older than 1 hour)
const cleanupOldUploads = async () => {
try {
const dir = await initUploadDir();
const files = await fs.readdir(dir);
const now = Date.now();
const oneHour = 60 * 60 * 1000;
for (const file of files) {
if (file.startsWith('.')) continue; // Skip hidden files
const filePath = path.join(dir, file);
try {
const stats = await fs.stat(filePath);
if (now - stats.mtimeMs > oneHour) {
await fs.unlink(filePath);
logger.debug(`Cleaned up old upload: ${file}`);
}
} catch (error) {
// Ignore errors for individual files
}
}
} catch (error) {
logger.debug('Error cleaning old uploads:', error.message);
}
};
// Run cleanup periodically (every 30 minutes)
setInterval(cleanupOldUploads, 30 * 60 * 1000);
module.exports = {
upload,
cleanupUploadedFiles,
cleanupOldUploads,
initUploadDir,
};
+321
View File
@@ -483,7 +483,328 @@ class MinIOService {
return name && name.length >= 1 && name.length <= 32 && regex.test(name); return name && name.length >= 1 && name.length <= 32 && regex.test(name);
} }
// ============================================
// Terminal Methods (Raw Command Execution)
// ============================================
async executeRawCommand(command, options = {}) {
const { timeout = 30000 } = options;
const startTime = Date.now();
// Security: Only allow mc commands
if (!command.trim().startsWith('mc ')) {
return {
success: false,
output: '',
error: 'Only mc commands are allowed',
exitCode: 1,
executionTime: Date.now() - startTime,
};
}
// Security: Block shell operators
const blockedPatterns = [/;/, /\|/, /`/, /\$\(/, />>?/, /<</, /&&/, /\|\|/];
for (const pattern of blockedPatterns) {
if (pattern.test(command)) {
return {
success: false,
output: '',
error: 'Command contains forbidden shell operators',
exitCode: 1,
executionTime: Date.now() - startTime,
};
}
}
try {
logger.debug(`Executing raw command: ${command}`);
const { stdout, stderr } = await execAsync(command, {
timeout,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, MC_NO_COLOR: '1' },
});
return {
success: true,
output: stdout || '',
error: stderr || '',
exitCode: 0,
executionTime: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
output: error.stdout || '',
error: error.stderr || error.message,
exitCode: error.code || 1,
executionTime: Date.now() - startTime,
};
}
}
// ============================================
// Extended Alias Management Methods
// ============================================
async getAlias(aliasName) {
const aliases = await this.listAliases();
const alias = aliases.find(a => a.alias === aliasName);
if (!alias) {
throw new AppError('Alias not found', 404);
}
return alias;
}
async updateAlias(aliasName, endpoint, accessKey, secretKey) {
if (!this.isValidAliasName(aliasName)) {
throw new AppError('Invalid alias name', 400);
}
// mc alias set overwrites existing alias
await this.executeCommand(
`mc alias set ${aliasName} ${endpoint} ${accessKey} ${secretKey}`
);
const testResult = await this.testConnection(aliasName);
return {
message: 'Alias updated successfully',
aliasName,
connectionStatus: testResult.status,
};
}
async removeAlias(aliasName) {
if (!this.isValidAliasName(aliasName)) {
throw new AppError('Invalid alias name', 400);
}
await this.executeCommand(`mc alias remove ${aliasName}`);
return { message: 'Alias removed successfully', aliasName };
}
// ============================================
// File Browser Methods
// ============================================
validatePath(pathStr) {
if (!pathStr) return true;
const decoded = decodeURIComponent(pathStr);
// Path traversal prevention
if (decoded.includes('..')) {
throw new AppError('Path traversal not allowed', 400);
}
// Null byte injection prevention
if (decoded.includes('\0')) {
throw new AppError('Invalid path', 400);
}
// Command injection prevention in paths
const dangerousPatterns = [';', '|', '`', '$', '>', '<', '&'];
for (const pattern of dangerousPatterns) {
if (decoded.includes(pattern)) {
throw new AppError('Invalid characters in path', 400);
}
}
return true;
}
async listObjects(alias, bucket, options = {}) {
const { prefix = '', recursive = false } = options;
// Validate path security
this.validatePath(prefix);
const targetPath = prefix
? `${alias}/${bucket}/${prefix}`
: `${alias}/${bucket}`;
const recursiveFlag = recursive ? '--recursive' : '';
try {
const output = await this.executeCommand(
`mc ls "${targetPath}" ${recursiveFlag} --json`,
{ parseJson: true, timeout: 60000 }
);
let objects = Array.isArray(output) ? output : output ? [output] : [];
// Transform results
objects = objects.map(obj => ({
key: obj.key || '',
name: (obj.key || '').split('/').filter(Boolean).pop() || obj.key || '',
size: obj.size || 0,
sizeFormatted: this.formatBytes(obj.size || 0),
lastModified: obj.lastModified || null,
type: (obj.key || '').endsWith('/') ? 'folder' : 'file',
etag: obj.etag || null,
}));
return {
objects,
prefix,
count: objects.length,
};
} catch (error) {
// Return empty list if path doesn't exist or is empty
if (error.message && error.message.includes('not found')) {
return { objects: [], prefix, count: 0 };
}
throw error;
}
}
async getObjectStat(alias, bucket, objectPath) {
this.validatePath(objectPath);
const output = await this.executeCommand(
`mc stat "${alias}/${bucket}/${objectPath}" --json`,
{ parseJson: true }
);
return {
key: objectPath,
name: objectPath.split('/').filter(Boolean).pop() || objectPath,
size: output.size || 0,
sizeFormatted: this.formatBytes(output.size || 0),
lastModified: output.lastModified || null,
contentType: output.metadata?.['content-type'] || 'application/octet-stream',
etag: output.etag || null,
metadata: output.metadata || {},
};
}
async downloadObject(alias, bucket, objectPath) {
this.validatePath(objectPath);
// Generate unique temp file path
const fileName = objectPath.split('/').filter(Boolean).pop() || 'download';
const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
const tempFilePath = path.join(
this.tempDir,
`${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}`
);
await this.executeCommand(
`mc cp "${alias}/${bucket}/${objectPath}" "${tempFilePath}"`,
{ timeout: 300000 } // 5 min for large files
);
return {
filePath: tempFilePath,
fileName,
cleanup: async () => {
await fs.unlink(tempFilePath).catch(() => {});
},
};
}
async uploadObject(alias, bucket, objectPath, localFilePath) {
this.validatePath(objectPath);
await this.executeCommand(
`mc cp "${localFilePath}" "${alias}/${bucket}/${objectPath}"`,
{ timeout: 300000 }
);
return { key: objectPath, message: 'File uploaded successfully' };
}
async deleteObject(alias, bucket, objectPath, recursive = false) {
this.validatePath(objectPath);
const recursiveFlag = recursive ? '--recursive --force' : '';
await this.executeCommand(
`mc rm "${alias}/${bucket}/${objectPath}" ${recursiveFlag}`,
{ timeout: 60000 }
);
return { message: 'Object deleted successfully', key: objectPath };
}
async renameObject(alias, bucket, sourcePath, destPath) {
this.validatePath(sourcePath);
this.validatePath(destPath);
// mc mv for rename
await this.executeCommand(
`mc mv "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
{ timeout: 60000 }
);
return {
message: 'Object renamed successfully',
source: sourcePath,
destination: destPath,
};
}
async copyObject(alias, bucket, sourcePath, destPath) {
this.validatePath(sourcePath);
this.validatePath(destPath);
await this.executeCommand(
`mc cp "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
{ timeout: 60000 }
);
return {
message: 'Object copied successfully',
source: sourcePath,
destination: destPath,
};
}
async createFolder(alias, bucket, folderPath) {
this.validatePath(folderPath);
// Ensure path ends with /
const normalizedPath = folderPath.endsWith('/') ? folderPath : `${folderPath}/`;
// Create empty object to represent folder (.keep file)
const emptyFile = path.join(this.tempDir, `.empty-${Date.now()}`);
await fs.writeFile(emptyFile, '');
try {
await this.executeCommand(
`mc cp "${emptyFile}" "${alias}/${bucket}/${normalizedPath}.keep"`,
{ timeout: 30000 }
);
} finally {
await fs.unlink(emptyFile).catch(() => {});
}
return { message: 'Folder created successfully', path: normalizedPath };
}
async getPathSize(alias, bucket, objectPath = '') {
this.validatePath(objectPath);
const targetPath = objectPath
? `${alias}/${bucket}/${objectPath}`
: `${alias}/${bucket}`;
const output = await this.executeCommand(
`mc du "${targetPath}" --json`,
{ parseJson: true, timeout: 120000 }
);
return {
path: objectPath || '/',
size: output.size || 0,
sizeFormatted: this.formatBytes(output.size || 0),
objects: output.objects || 0,
};
}
// ============================================
// Utility Methods // Utility Methods
// ============================================
formatBytes(bytes) { formatBytes(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
+11
View File
@@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets';
import Users from './components/Users/Users'; import Users from './components/Users/Users';
import Policies from './components/Policies/Policies'; import Policies from './components/Policies/Policies';
import Reports from './components/Reports/Reports'; import Reports from './components/Reports/Reports';
import Explorer from './components/Explorer/Explorer';
import Aliases from './components/Explorer/Aliases/Aliases';
import Terminal from './components/Explorer/Terminal/Terminal';
import FileBrowser from './components/Explorer/FileBrowser/FileBrowser';
import './i18n'; import './i18n';
function App() { function App() {
@@ -30,6 +34,13 @@ function App() {
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/buckets" element={<Buckets />} /> <Route path="/buckets" element={<Buckets />} />
<Route path="/explorer" element={<Explorer />}>
<Route index element={<Navigate to="aliases" replace />} />
<Route path="aliases" element={<Aliases />} />
<Route path="terminal" element={<Terminal />} />
<Route path="browse/:alias/:bucket" element={<FileBrowser />} />
<Route path="browse/:alias/:bucket/*" element={<FileBrowser />} />
</Route>
<Route path="/users" element={<Users />} /> <Route path="/users" element={<Users />} />
<Route path="/policies" element={<Policies />} /> <Route path="/policies" element={<Policies />} />
<Route path="/reports" element={<Reports />} /> <Route path="/reports" element={<Reports />} />
@@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Alert,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, Alias } from '../../../store/explorerStore';
interface AliasDialogProps {
open: boolean;
alias: Alias | null;
onClose: () => void;
onSuccess: () => void;
}
const AliasDialog: React.FC<AliasDialogProps> = ({ open, alias, onClose, onSuccess }) => {
const { t } = useTranslation(['explorer', 'common']);
const { addAlias, updateAlias } = useExplorerStore();
const [aliasName, setAliasName] = useState('');
const [endpoint, setEndpoint] = useState('');
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEdit = !!alias;
useEffect(() => {
if (open) {
if (alias) {
setAliasName(alias.alias);
setEndpoint(alias.URL);
setAccessKey('');
setSecretKey('');
} else {
setAliasName('');
setEndpoint('');
setAccessKey('');
setSecretKey('');
}
setError('');
}
}, [open, alias]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validation
if (!aliasName.trim()) {
setError('Alias name is required');
return;
}
if (!endpoint.trim()) {
setError('Endpoint is required');
return;
}
if (!accessKey.trim()) {
setError('Access key is required');
return;
}
if (!secretKey.trim()) {
setError('Secret key is required');
return;
}
setLoading(true);
try {
if (isEdit) {
await updateAlias(aliasName, endpoint, accessKey, secretKey);
} else {
await addAlias(aliasName, endpoint, accessKey, secretKey);
}
onSuccess();
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Operation failed');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>
{t('explorer:aliases.dialog.title')}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{error && (
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
)}
<TextField
label={t('explorer:aliases.dialog.aliasName')}
value={aliasName}
onChange={(e) => setAliasName(e.target.value)}
disabled={isEdit}
required
fullWidth
helperText={t('explorer:aliases.dialog.aliasNameHelper')}
inputProps={{ pattern: '[a-zA-Z0-9_-]+' }}
/>
<TextField
label={t('explorer:aliases.dialog.endpoint')}
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
fullWidth
placeholder="https://minio.example.com"
helperText={t('explorer:aliases.dialog.endpointHelper')}
/>
<TextField
label={t('explorer:aliases.dialog.accessKey')}
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
required
fullWidth
/>
<TextField
label={t('explorer:aliases.dialog.secretKey')}
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
required
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
{t('common:actions.cancel')}
</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{isEdit ? t('common:actions.save') : t('common:actions.create')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default AliasDialog;
@@ -0,0 +1,309 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Chip,
Typography,
Tooltip,
CircularProgress,
Snackbar,
Alert,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
NetworkCheck as TestIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, Alias } from '../../../store/explorerStore';
import AliasDialog from './AliasDialog';
import ConfirmDialog from '../../shared/ConfirmDialog';
import api from '../../../services/api';
const Aliases: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const navigate = useNavigate();
const { aliases, aliasesLoading, loadAliases, removeAlias, testConnection } = useExplorerStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingAlias, setEditingAlias] = useState<Alias | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [aliasToDelete, setAliasToDelete] = useState<string | null>(null);
const [testingAlias, setTestingAlias] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
useEffect(() => {
loadAliases();
}, [loadAliases]);
const handleAddAlias = () => {
setEditingAlias(null);
setDialogOpen(true);
};
const handleEditAlias = (alias: Alias) => {
setEditingAlias(alias);
setDialogOpen(true);
};
const handleDeleteClick = (aliasName: string) => {
setAliasToDelete(aliasName);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (aliasToDelete) {
try {
await removeAlias(aliasToDelete);
setSnackbar({
open: true,
message: t('explorer:aliases.messages.deleted'),
severity: 'success',
});
} catch (error: any) {
setSnackbar({
open: true,
message: error.response?.data?.message || 'Delete failed',
severity: 'error',
});
}
}
setDeleteDialogOpen(false);
setAliasToDelete(null);
};
const handleTestConnection = async (aliasName: string) => {
setTestingAlias(aliasName);
try {
const result = await testConnection(aliasName);
setSnackbar({
open: true,
message: result.status === 'connected'
? t('explorer:aliases.messages.testSuccess')
: t('explorer:aliases.messages.testFailed'),
severity: result.status === 'connected' ? 'success' : 'error',
});
// Reload to update status
loadAliases();
} catch (error) {
setSnackbar({
open: true,
message: t('explorer:aliases.messages.testFailed'),
severity: 'error',
});
}
setTestingAlias(null);
};
const handleAliasClick = async (alias: Alias) => {
// Fetch buckets for this alias and navigate to first one, or show bucket picker
try {
const response = await api.get<{ buckets: { key: string }[] }>('/buckets');
if (response.data.buckets && response.data.buckets.length > 0) {
// Navigate to first bucket
navigate(`/explorer/browse/${alias.alias}/${response.data.buckets[0].key}`);
} else {
setSnackbar({
open: true,
message: 'No buckets found for this alias',
severity: 'error',
});
}
} catch (error: any) {
setSnackbar({
open: true,
message: error.response?.data?.message || 'Failed to load buckets',
severity: 'error',
});
}
};
const handleDialogSuccess = () => {
setDialogOpen(false);
loadAliases();
setSnackbar({
open: true,
message: editingAlias
? t('explorer:aliases.messages.updated')
: t('explorer:aliases.messages.created'),
severity: 'success',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'connected':
return 'success';
case 'disconnected':
return 'error';
default:
return 'default';
}
};
if (aliasesLoading && aliases.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">{t('explorer:aliases.title')}</Typography>
<Box>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => loadAliases()}
sx={{ mr: 1 }}
>
{t('common:actions.refresh')}
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddAlias}
>
{t('explorer:aliases.addAlias')}
</Button>
</Box>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('explorer:aliases.columns.name')}</TableCell>
<TableCell>{t('explorer:aliases.columns.endpoint')}</TableCell>
<TableCell>{t('explorer:aliases.columns.status')}</TableCell>
<TableCell align="right">{t('explorer:aliases.columns.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{aliases.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Box py={4}>
<Typography variant="h6" color="text.secondary">
{t('explorer:aliases.empty.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:aliases.empty.message')}
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
aliases.map((alias) => (
<TableRow
key={alias.alias}
hover
sx={{ cursor: 'pointer' }}
onClick={() => handleAliasClick(alias)}
>
<TableCell>
<Typography variant="body1" fontWeight="medium">
{alias.alias}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{alias.URL}
</Typography>
</TableCell>
<TableCell>
<Chip
label={t(`explorer:aliases.status.${alias.status}`)}
color={getStatusColor(alias.status)}
size="small"
/>
</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('explorer:aliases.testConnection')}>
<IconButton
size="small"
onClick={() => handleTestConnection(alias.alias)}
disabled={testingAlias === alias.alias}
>
{testingAlias === alias.alias ? (
<CircularProgress size={20} />
) : (
<TestIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:aliases.editAlias')}>
<IconButton
size="small"
onClick={() => handleEditAlias(alias)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:aliases.deleteAlias')}>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(alias.alias)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<AliasDialog
open={dialogOpen}
alias={editingAlias}
onClose={() => setDialogOpen(false)}
onSuccess={handleDialogSuccess}
/>
<ConfirmDialog
open={deleteDialogOpen}
title={t('explorer:aliases.deleteAlias')}
message={t('common:confirmDialog.deleteMessage', { item: aliasToDelete })}
confirmText={t('common:actions.delete')}
confirmColor="error"
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteDialogOpen(false)}
/>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};
export default Aliases;
@@ -0,0 +1,62 @@
import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Box, Typography, Tabs, Tab, Paper } from '@mui/material';
import {
Storage as StorageIcon,
Terminal as TerminalIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
const Explorer: React.FC = () => {
const { t } = useTranslation(['explorer']);
const navigate = useNavigate();
const location = useLocation();
// Determine current tab based on URL
const getCurrentTab = () => {
if (location.pathname.includes('/terminal')) return 1;
if (location.pathname.includes('/browse')) return -1; // Hide tabs when browsing
return 0; // aliases
};
const currentTab = getCurrentTab();
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
if (newValue === 0) navigate('/explorer/aliases');
else if (newValue === 1) navigate('/explorer/terminal');
};
return (
<Box>
<Typography variant="h4" sx={{ mb: 3 }}>
{t('explorer:title')}
</Typography>
{currentTab !== -1 && (
<Paper sx={{ mb: 3 }}>
<Tabs
value={currentTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
>
<Tab
icon={<StorageIcon />}
iconPosition="start"
label={t('explorer:tabs.aliases')}
/>
<Tab
icon={<TerminalIcon />}
iconPosition="start"
label={t('explorer:tabs.terminal')}
/>
</Tabs>
</Paper>
)}
<Outlet />
</Box>
);
};
export default Explorer;
@@ -0,0 +1,103 @@
import React from 'react';
import { Breadcrumbs, Link, Typography, Box } from '@mui/material';
import {
NavigateNext as NavigateNextIcon,
Storage as StorageIcon,
Home as HomeIcon,
} from '@mui/icons-material';
interface BreadcrumbNavProps {
alias: string;
bucket: string;
path: string;
onNavigate: (path: string) => void;
}
const BreadcrumbNav: React.FC<BreadcrumbNavProps> = ({
alias,
bucket,
path,
onNavigate,
}) => {
const pathParts = path ? path.split('/').filter(Boolean) : [];
const handleClick = (index: number) => {
if (index === -1) {
// Navigate to bucket root
onNavigate('');
} else {
// Navigate to specific path
const newPath = pathParts.slice(0, index + 1).join('/');
onNavigate(newPath);
}
};
return (
<Box sx={{ mb: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
{/* Alias link - goes back to aliases list */}
<Link
component="button"
variant="body1"
onClick={() => window.location.href = '/explorer/aliases'}
underline="hover"
color="inherit"
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<HomeIcon sx={{ mr: 0.5 }} fontSize="small" />
{alias}
</Link>
{/* Bucket link */}
{pathParts.length > 0 ? (
<Link
component="button"
variant="body1"
onClick={() => handleClick(-1)}
underline="hover"
color="inherit"
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
{bucket}
</Link>
) : (
<Typography
color="text.primary"
sx={{ display: 'flex', alignItems: 'center' }}
>
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
{bucket}
</Typography>
)}
{/* Path parts */}
{pathParts.map((part, index) => {
const isLast = index === pathParts.length - 1;
return isLast ? (
<Typography key={index} color="text.primary">
{part}
</Typography>
) : (
<Link
key={index}
component="button"
variant="body1"
onClick={() => handleClick(index)}
underline="hover"
color="inherit"
sx={{ cursor: 'pointer' }}
>
{part}
</Link>
);
})}
</Breadcrumbs>
</Box>
);
};
export default BreadcrumbNav;
@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
interface CreateFolderDialogProps {
open: boolean;
onClose: () => void;
onConfirm: (name: string) => void;
}
const CreateFolderDialog: React.FC<CreateFolderDialogProps> = ({
open,
onClose,
onConfirm,
}) => {
const { t } = useTranslation(['explorer', 'common']);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName('');
setError('');
}
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError(t('explorer:browser.createFolderDialog.errors.required'));
return;
}
// Validate folder name
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
setError(t('explorer:browser.createFolderDialog.errors.invalidChars'));
return;
}
if (trimmedName.startsWith('.')) {
setError(t('explorer:browser.createFolderDialog.errors.dotStart'));
return;
}
onConfirm(trimmedName);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>{t('explorer:browser.createFolderDialog.title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t('explorer:browser.createFolderDialog.nameLabel')}
fullWidth
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
error={!!error}
helperText={error || t('explorer:browser.createFolderDialog.nameHelper')}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
<Button type="submit" variant="contained">
{t('common:actions.create')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default CreateFolderDialog;
@@ -0,0 +1,480 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Typography,
Tooltip,
CircularProgress,
Snackbar,
Alert,
Button,
Checkbox,
Toolbar,
} from '@mui/material';
import {
Folder as FolderIcon,
InsertDriveFile as FileIcon,
Download as DownloadIcon,
Delete as DeleteIcon,
Edit as EditIcon,
CreateNewFolder as CreateFolderIcon,
CloudUpload as UploadIcon,
Refresh as RefreshIcon,
ArrowBack as BackIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, FileEntry } from '../../../store/explorerStore';
import BreadcrumbNav from './BreadcrumbNav';
import UploadDialog from './UploadDialog';
import CreateFolderDialog from './CreateFolderDialog';
import RenameDialog from './RenameDialog';
import ConfirmDialog from '../../shared/ConfirmDialog';
const FileBrowser: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const { alias, bucket, '*': pathParam } = useParams();
const navigate = useNavigate();
const {
files,
filesLoading,
loadFiles,
downloadFile,
deleteFile,
renameFile,
createFolder,
uploadFiles,
} = useExplorerStore();
const currentPath = pathParam || '';
const [selected, setSelected] = useState<string[]>([]);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToRename, setItemToRename] = useState<FileEntry | null>(null);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
useEffect(() => {
if (alias && bucket) {
loadFiles(alias, bucket, currentPath);
setSelected([]);
}
}, [alias, bucket, currentPath, loadFiles]);
const handleNavigate = useCallback((path: string) => {
if (alias && bucket) {
const newPath = path ? `/explorer/browse/${alias}/${bucket}/${path}` : `/explorer/browse/${alias}/${bucket}`;
navigate(newPath);
}
}, [alias, bucket, navigate]);
const handleItemClick = useCallback((item: FileEntry) => {
if (item.type === 'folder') {
const newPath = currentPath ? `${currentPath}/${item.name}` : item.name;
handleNavigate(newPath);
}
}, [currentPath, handleNavigate]);
const handleBack = useCallback(() => {
if (currentPath) {
const parts = currentPath.split('/');
parts.pop();
handleNavigate(parts.join('/'));
} else {
navigate('/explorer/aliases');
}
}, [currentPath, handleNavigate, navigate]);
const handleSelectAll = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelected(files.map((f) => f.key));
} else {
setSelected([]);
}
}, [files]);
const handleSelect = useCallback((key: string) => {
setSelected((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
}, []);
const handleDownload = useCallback(async (item: FileEntry) => {
if (!alias || !bucket) return;
try {
await downloadFile(alias, bucket, item.key);
setSnackbar({
open: true,
message: t('explorer:browser.messages.downloadStarted'),
severity: 'success',
});
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.downloadFailed'),
severity: 'error',
});
}
}, [alias, bucket, downloadFile, t]);
const handleRenameClick = useCallback((item: FileEntry) => {
setItemToRename(item);
setRenameDialogOpen(true);
}, []);
const handleRenameConfirm = useCallback(async (newName: string) => {
if (!alias || !bucket || !itemToRename) return;
try {
await renameFile(alias, bucket, itemToRename.key, newName);
setSnackbar({
open: true,
message: t('explorer:browser.messages.renamed'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.renameFailed'),
severity: 'error',
});
}
setRenameDialogOpen(false);
setItemToRename(null);
}, [alias, bucket, itemToRename, renameFile, loadFiles, currentPath, t]);
const handleDeleteClick = useCallback((key: string) => {
setItemToDelete(key);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!alias || !bucket || !itemToDelete) return;
try {
await deleteFile(alias, bucket, itemToDelete);
setSnackbar({
open: true,
message: t('explorer:browser.messages.deleted'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.deleteFailed'),
severity: 'error',
});
}
setDeleteDialogOpen(false);
setItemToDelete(null);
}, [alias, bucket, itemToDelete, deleteFile, loadFiles, currentPath, t]);
const handleBulkDelete = useCallback(async () => {
if (!alias || !bucket || selected.length === 0) return;
try {
for (const key of selected) {
await deleteFile(alias, bucket, key);
}
setSnackbar({
open: true,
message: t('explorer:browser.messages.deleted'),
severity: 'success',
});
setSelected([]);
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.deleteFailed'),
severity: 'error',
});
}
}, [alias, bucket, selected, deleteFile, loadFiles, currentPath, t]);
const handleCreateFolder = useCallback(async (name: string) => {
if (!alias || !bucket) return;
try {
const folderPath = currentPath ? `${currentPath}/${name}` : name;
await createFolder(alias, bucket, folderPath);
setSnackbar({
open: true,
message: t('explorer:browser.messages.folderCreated'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.folderCreateFailed'),
severity: 'error',
});
}
setCreateFolderDialogOpen(false);
}, [alias, bucket, currentPath, createFolder, loadFiles, t]);
const handleUpload = useCallback(async (uploadedFiles: File[]) => {
if (!alias || !bucket) return;
try {
await uploadFiles(alias, bucket, uploadedFiles, currentPath);
setSnackbar({
open: true,
message: t('explorer:browser.messages.uploaded'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.uploadFailed'),
severity: 'error',
});
}
setUploadDialogOpen(false);
}, [alias, bucket, currentPath, uploadFiles, loadFiles, t]);
const formatSize = (bytes: number): string => {
if (bytes === 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
const formatDate = (date: string | null): string => {
if (!date) return '-';
return new Date(date).toLocaleString();
};
if (!alias || !bucket) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<Typography color="error">{t('explorer:browser.errors.missingParams')}</Typography>
</Box>
);
}
return (
<Box>
{/* Breadcrumb navigation */}
<BreadcrumbNav
alias={alias}
bucket={bucket}
path={currentPath}
onNavigate={handleNavigate}
/>
{/* Toolbar */}
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: selected.length > 0 ? 'action.selected' : 'transparent',
mb: 2,
}}
>
{selected.length > 0 ? (
<>
<Typography sx={{ flex: '1 1 100%' }} color="inherit" variant="subtitle1">
{t('explorer:browser.selected', { count: selected.length })}
</Typography>
<Tooltip title={t('common:actions.delete')}>
<IconButton onClick={handleBulkDelete} color="error">
<DeleteIcon />
</IconButton>
</Tooltip>
</>
) : (
<>
<Tooltip title={t('common:actions.back')}>
<IconButton onClick={handleBack} sx={{ mr: 1 }}>
<BackIcon />
</IconButton>
</Tooltip>
<Typography sx={{ flex: '1 1 100%' }} variant="h6">
{bucket}
</Typography>
<Tooltip title={t('common:actions.refresh')}>
<IconButton onClick={() => loadFiles(alias, bucket, currentPath)}>
<RefreshIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:browser.createFolder')}>
<IconButton onClick={() => setCreateFolderDialogOpen(true)}>
<CreateFolderIcon />
</IconButton>
</Tooltip>
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={() => setUploadDialogOpen(true)}
sx={{ ml: 1 }}
>
{t('explorer:browser.upload')}
</Button>
</>
)}
</Toolbar>
{/* File table */}
{filesLoading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selected.length > 0 && selected.length < files.length}
checked={files.length > 0 && selected.length === files.length}
onChange={handleSelectAll}
/>
</TableCell>
<TableCell>{t('explorer:browser.columns.name')}</TableCell>
<TableCell>{t('explorer:browser.columns.size')}</TableCell>
<TableCell>{t('explorer:browser.columns.type')}</TableCell>
<TableCell>{t('explorer:browser.columns.lastModified')}</TableCell>
<TableCell align="right">{t('explorer:browser.columns.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Box py={4}>
<Typography variant="h6" color="text.secondary">
{t('explorer:browser.empty.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:browser.empty.message')}
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
files.map((item) => (
<TableRow
key={item.key}
hover
sx={{ cursor: item.type === 'folder' ? 'pointer' : 'default' }}
onClick={() => handleItemClick(item)}
>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.includes(item.key)}
onChange={() => handleSelect(item.key)}
/>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
{item.type === 'folder' ? (
<FolderIcon color="primary" />
) : (
<FileIcon color="action" />
)}
<Typography variant="body2">{item.name}</Typography>
</Box>
</TableCell>
<TableCell>{formatSize(item.size)}</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{item.type === 'folder' ? t('explorer:browser.types.folder') : (item.contentType || t('explorer:browser.types.file'))}
</Typography>
</TableCell>
<TableCell>{formatDate(item.lastModified)}</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{item.type !== 'folder' && (
<Tooltip title={t('explorer:browser.download')}>
<IconButton size="small" onClick={() => handleDownload(item)}>
<DownloadIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={t('explorer:browser.rename')}>
<IconButton size="small" onClick={() => handleRenameClick(item)}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('common:actions.delete')}>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(item.key)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
{/* Dialogs */}
<UploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
onUpload={handleUpload}
/>
<CreateFolderDialog
open={createFolderDialogOpen}
onClose={() => setCreateFolderDialogOpen(false)}
onConfirm={handleCreateFolder}
/>
<RenameDialog
open={renameDialogOpen}
currentName={itemToRename?.name || ''}
onClose={() => {
setRenameDialogOpen(false);
setItemToRename(null);
}}
onConfirm={handleRenameConfirm}
/>
<ConfirmDialog
open={deleteDialogOpen}
title={t('explorer:browser.deleteItem')}
message={t('common:confirmDialog.deleteMessage', { item: itemToDelete?.split('/').pop() || '' })}
confirmText={t('common:actions.delete')}
confirmColor="error"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setDeleteDialogOpen(false);
setItemToDelete(null);
}}
/>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};
export default FileBrowser;
@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
interface RenameDialogProps {
open: boolean;
currentName: string;
onClose: () => void;
onConfirm: (newName: string) => void;
}
const RenameDialog: React.FC<RenameDialogProps> = ({
open,
currentName,
onClose,
onConfirm,
}) => {
const { t } = useTranslation(['explorer', 'common']);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName(currentName);
setError('');
}
}, [open, currentName]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError(t('explorer:browser.renameDialog.errors.required'));
return;
}
if (trimmedName === currentName) {
setError(t('explorer:browser.renameDialog.errors.same'));
return;
}
// Validate name
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
setError(t('explorer:browser.renameDialog.errors.invalidChars'));
return;
}
onConfirm(trimmedName);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>{t('explorer:browser.renameDialog.title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t('explorer:browser.renameDialog.newNameLabel')}
fullWidth
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
error={!!error}
helperText={error}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
<Button type="submit" variant="contained">
{t('explorer:browser.rename')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default RenameDialog;
@@ -0,0 +1,197 @@
import React, { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
IconButton,
LinearProgress,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
InsertDriveFile as FileIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
interface UploadDialogProps {
open: boolean;
onClose: () => void;
onUpload: (files: File[]) => Promise<void>;
}
const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, onUpload }) => {
const { t } = useTranslation(['explorer', 'common']);
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [...prev, ...droppedFiles]);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [...prev, ...selectedFiles]);
}
}, []);
const handleRemoveFile = useCallback((index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpload = useCallback(async () => {
if (files.length === 0) return;
setUploading(true);
try {
await onUpload(files);
setFiles([]);
} finally {
setUploading(false);
}
}, [files, onUpload]);
const handleClose = useCallback(() => {
if (!uploading) {
setFiles([]);
onClose();
}
}, [uploading, onClose]);
const formatSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('explorer:browser.uploadDialog.title')}</DialogTitle>
<DialogContent>
{/* Drop zone */}
<Box
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
sx={{
border: 2,
borderStyle: 'dashed',
borderColor: dragOver ? 'primary.main' : 'grey.400',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: dragOver ? 'action.hover' : 'transparent',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
},
}}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<UploadIcon sx={{ fontSize: 48, color: 'grey.500', mb: 1 }} />
<Typography variant="h6" color="text.secondary">
{t('explorer:browser.uploadDialog.dropzone')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:browser.uploadDialog.or')}
</Typography>
<Button variant="outlined" sx={{ mt: 1 }}>
{t('explorer:browser.uploadDialog.browse')}
</Button>
</Box>
{/* File list */}
{files.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t('explorer:browser.uploadDialog.selectedFiles', { count: files.length })}
</Typography>
<List dense sx={{ maxHeight: 200, overflow: 'auto' }}>
{files.map((file, index) => (
<ListItem
key={index}
secondaryAction={
<IconButton
edge="end"
size="small"
onClick={() => handleRemoveFile(index)}
disabled={uploading}
>
<DeleteIcon />
</IconButton>
}
>
<ListItemIcon>
<FileIcon />
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={formatSize(file.size)}
/>
</ListItem>
))}
</List>
</Box>
)}
{/* Upload progress */}
{uploading && (
<Box sx={{ mt: 2 }}>
<LinearProgress />
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 1 }}>
{t('explorer:browser.uploadDialog.uploading')}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={uploading}>
{t('common:actions.cancel')}
</Button>
<Button
variant="contained"
onClick={handleUpload}
disabled={files.length === 0 || uploading}
startIcon={<UploadIcon />}
>
{t('explorer:browser.upload')}
</Button>
</DialogActions>
</Dialog>
);
};
export default UploadDialog;
@@ -0,0 +1,214 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Box,
TextField,
IconButton,
Typography,
Paper,
Tooltip,
CircularProgress,
} from '@mui/material';
import {
Clear as ClearIcon,
Send as SendIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useTerminalStore } from '../../../store/terminalStore';
const Terminal: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const {
commandHistory,
outputHistory,
isExecuting,
executeCommand,
clearOutput,
} = useTerminalStore();
const [inputValue, setInputValue] = useState('');
const [historyIndex, setHistoryIndex] = useState(-1);
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [outputHistory]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
const command = inputValue.trim();
if (!command || isExecuting) return;
setInputValue('');
setHistoryIndex(-1);
await executeCommand(command);
}, [inputValue, isExecuting, executeCommand]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length === 0) return;
const newIndex = historyIndex < commandHistory.length - 1
? historyIndex + 1
: historyIndex;
setHistoryIndex(newIndex);
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex <= 0) {
setHistoryIndex(-1);
setInputValue('');
} else {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
}
}
}, [commandHistory, historyIndex]);
const handleClear = useCallback(() => {
clearOutput();
inputRef.current?.focus();
}, [clearOutput]);
return (
<Box sx={{ height: 'calc(100vh - 250px)', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">{t('explorer:terminal.title')}</Typography>
<Tooltip title={t('explorer:terminal.clear')}>
<IconButton onClick={handleClear} size="small">
<ClearIcon />
</IconButton>
</Tooltip>
</Box>
<Paper
sx={{
flex: 1,
bgcolor: 'grey.900',
color: 'grey.100',
fontFamily: 'monospace',
fontSize: '14px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Output area */}
<Box
ref={outputRef}
sx={{
flex: 1,
overflow: 'auto',
p: 2,
'& pre': {
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
}}
>
{outputHistory.length === 0 ? (
<Typography
variant="body2"
sx={{ color: 'grey.500', fontFamily: 'monospace' }}
>
{t('explorer:terminal.welcomeMessage')}
</Typography>
) : (
outputHistory.map((entry) => (
<Box key={entry.id} sx={{ mb: 2 }}>
<Box sx={{ color: 'primary.light', mb: 0.5 }}>
<span style={{ color: '#4caf50' }}>$</span> {entry.command}
</Box>
{entry.output && (
<pre style={{ color: '#e0e0e0' }}>{entry.output}</pre>
)}
{entry.error && (
<pre style={{ color: '#f44336' }}>{entry.error}</pre>
)}
</Box>
))
)}
{isExecuting && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'grey.500' }}>
<CircularProgress size={16} color="inherit" />
<span>{t('explorer:terminal.executing')}</span>
</Box>
)}
</Box>
{/* Input area */}
<Box
component="form"
onSubmit={handleSubmit}
sx={{
display: 'flex',
alignItems: 'center',
borderTop: 1,
borderColor: 'grey.800',
p: 1,
bgcolor: 'grey.800',
}}
>
<Typography
component="span"
sx={{ color: '#4caf50', mr: 1, fontFamily: 'monospace' }}
>
$
</Typography>
<TextField
inputRef={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isExecuting}
placeholder={t('explorer:terminal.placeholder')}
variant="standard"
fullWidth
autoComplete="off"
InputProps={{
disableUnderline: true,
sx: {
fontFamily: 'monospace',
fontSize: '14px',
color: 'grey.100',
'& input::placeholder': {
color: 'grey.600',
opacity: 1,
},
},
}}
/>
<Tooltip title={t('explorer:terminal.execute')}>
<span>
<IconButton
type="submit"
disabled={isExecuting || !inputValue.trim()}
size="small"
sx={{ color: 'grey.400' }}
>
<SendIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Paper>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
{t('explorer:terminal.helpText')}
</Typography>
</Box>
);
};
export default Terminal;
+3 -1
View File
@@ -25,6 +25,7 @@ import {
ChevronLeft as ChevronLeftIcon, ChevronLeft as ChevronLeftIcon,
Dashboard as DashboardIcon, Dashboard as DashboardIcon,
Storage as StorageIcon, Storage as StorageIcon,
FolderOpen as FolderOpenIcon,
People as PeopleIcon, People as PeopleIcon,
Policy as PolicyIcon, Policy as PolicyIcon,
Assessment as AssessmentIcon, Assessment as AssessmentIcon,
@@ -55,6 +56,7 @@ const Layout: React.FC = () => {
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' }, { text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' },
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets', translationKey: 'navigation.buckets' }, { text: 'Buckets', icon: <StorageIcon />, path: '/buckets', translationKey: 'navigation.buckets' },
{ text: 'Explorer', icon: <FolderOpenIcon />, path: '/explorer', translationKey: 'navigation.explorer' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users', translationKey: 'navigation.users' }, { text: 'Users', icon: <PeopleIcon />, path: '/users', translationKey: 'navigation.users' },
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' }, { text: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' },
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' }, { text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' },
@@ -224,7 +226,7 @@ const Layout: React.FC = () => {
{navItems.map((item) => ( {navItems.map((item) => (
<ListItem key={item.text} disablePadding> <ListItem key={item.text} disablePadding>
<ListItemButton <ListItemButton
selected={location.pathname === item.path} selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
> >
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
+5 -1
View File
@@ -8,12 +8,14 @@ import enDashboard from './locales/en/dashboard.json';
import enQuickWizard from './locales/en/quickWizard.json'; import enQuickWizard from './locales/en/quickWizard.json';
import enReports from './locales/en/reports.json'; import enReports from './locales/en/reports.json';
import enErrors from './locales/en/errors.json'; import enErrors from './locales/en/errors.json';
import enExplorer from './locales/en/explorer.json';
import deCommon from './locales/de/common.json'; import deCommon from './locales/de/common.json';
import deDashboard from './locales/de/dashboard.json'; import deDashboard from './locales/de/dashboard.json';
import deQuickWizard from './locales/de/quickWizard.json'; import deQuickWizard from './locales/de/quickWizard.json';
import deReports from './locales/de/reports.json'; import deReports from './locales/de/reports.json';
import deErrors from './locales/de/errors.json'; import deErrors from './locales/de/errors.json';
import deExplorer from './locales/de/explorer.json';
const resources = { const resources = {
en: { en: {
@@ -22,6 +24,7 @@ const resources = {
quickWizard: enQuickWizard, quickWizard: enQuickWizard,
reports: enReports, reports: enReports,
errors: enErrors, errors: enErrors,
explorer: enExplorer,
}, },
de: { de: {
common: deCommon, common: deCommon,
@@ -29,6 +32,7 @@ const resources = {
quickWizard: deQuickWizard, quickWizard: deQuickWizard,
reports: deReports, reports: deReports,
errors: deErrors, errors: deErrors,
explorer: deExplorer,
}, },
}; };
@@ -39,7 +43,7 @@ i18n
resources, resources,
lng: 'de', // Default to German lng: 'de', // Default to German
fallbackLng: 'en', fallbackLng: 'en',
ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors'], ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors', 'explorer'],
defaultNS: 'common', defaultNS: 'common',
interpolation: { interpolation: {
escapeValue: false, // React already escapes values escapeValue: false, // React already escapes values
+1
View File
@@ -3,6 +3,7 @@
"navigation": { "navigation": {
"dashboard": "Übersicht", "dashboard": "Übersicht",
"buckets": "Buckets", "buckets": "Buckets",
"explorer": "Explorer",
"users": "Benutzer", "users": "Benutzer",
"policies": "Richtlinien", "policies": "Richtlinien",
"reports": "Berichte", "reports": "Berichte",
+133
View File
@@ -0,0 +1,133 @@
{
"title": "Speicher-Explorer",
"tabs": {
"aliases": "Aliase",
"terminal": "Terminal"
},
"aliases": {
"title": "MinIO Aliase",
"addAlias": "Alias hinzufügen",
"editAlias": "Alias bearbeiten",
"deleteAlias": "Alias löschen",
"testConnection": "Verbindung testen",
"columns": {
"name": "Name",
"endpoint": "Endpunkt",
"status": "Status",
"actions": "Aktionen"
},
"status": {
"connected": "Verbunden",
"disconnected": "Getrennt",
"unknown": "Unbekannt",
"testing": "Teste..."
},
"dialog": {
"title": "Alias-Konfiguration",
"aliasName": "Alias-Name",
"endpoint": "Endpunkt-URL",
"accessKey": "Zugriffsschlüssel",
"secretKey": "Geheimer Schlüssel",
"aliasNameHelper": "Ein eindeutiger Name für diese Verbindung (z.B. produktion, staging)",
"endpointHelper": "MinIO Server-URL (z.B. https://minio.example.com)"
},
"empty": {
"title": "Keine Aliase konfiguriert",
"message": "Fügen Sie einen MinIO-Alias hinzu, um Dateien zu durchsuchen"
},
"messages": {
"created": "Alias erfolgreich erstellt",
"updated": "Alias erfolgreich aktualisiert",
"deleted": "Alias erfolgreich gelöscht",
"testSuccess": "Verbindung erfolgreich",
"testFailed": "Verbindung fehlgeschlagen"
}
},
"terminal": {
"title": "MinIO CLI Terminal",
"placeholder": "mc Befehl eingeben (z.B. mc ls myminio)",
"clear": "Löschen",
"execute": "Ausführen",
"executing": "Wird ausgeführt...",
"running": "Wird ausgeführt...",
"hint": "Pfeiltasten für Befehlsverlauf verwenden",
"welcomeMessage": "Willkommen im MinIO CLI Terminal. Geben Sie 'mc' Befehle ein, um mit Ihren MinIO-Servern zu interagieren.",
"helpText": "Tipp: Verwenden Sie Pfeiltasten hoch/runter für den Befehlsverlauf"
},
"browser": {
"title": "Datei-Browser",
"selectBucket": "Bucket auswählen",
"upload": "Hochladen",
"createFolder": "Ordner erstellen",
"newFolder": "Neuer Ordner",
"download": "Herunterladen",
"delete": "Löschen",
"rename": "Umbenennen",
"copy": "Kopieren",
"refresh": "Aktualisieren",
"deleteItem": "Element löschen",
"selected": "{{count}} Element(e) ausgewählt",
"columns": {
"name": "Name",
"size": "Größe",
"type": "Typ",
"lastModified": "Zuletzt geändert",
"actions": "Aktionen"
},
"types": {
"file": "Datei",
"folder": "Ordner"
},
"empty": {
"title": "Keine Dateien",
"message": "Dieser Ordner ist leer. Laden Sie Dateien hoch oder erstellen Sie einen neuen Ordner."
},
"errors": {
"missingParams": "Fehlender Alias- oder Bucket-Parameter"
},
"uploadDialog": {
"title": "Dateien hochladen",
"dropzone": "Dateien hierher ziehen",
"or": "oder",
"browse": "Dateien durchsuchen",
"selectedFiles": "{{count}} Datei(en) ausgewählt",
"uploading": "Wird hochgeladen..."
},
"createFolderDialog": {
"title": "Ordner erstellen",
"nameLabel": "Ordnername",
"nameHelper": "Geben Sie einen Namen für den neuen Ordner ein",
"errors": {
"required": "Ordnername ist erforderlich",
"invalidChars": "Ordnername darf kein / oder \\ enthalten",
"dotStart": "Ordnername darf nicht mit einem Punkt beginnen"
}
},
"renameDialog": {
"title": "Umbenennen",
"newNameLabel": "Neuer Name",
"errors": {
"required": "Name ist erforderlich",
"same": "Neuer Name muss sich vom aktuellen Namen unterscheiden",
"invalidChars": "Name darf kein / oder \\ enthalten"
}
},
"deleteConfirm": {
"title": "{{name}} löschen?",
"message": "Diese Aktion kann nicht rückgängig gemacht werden.",
"folderWarning": "Dieser Ordner und sein gesamter Inhalt werden dauerhaft gelöscht."
},
"messages": {
"folderCreated": "Ordner erfolgreich erstellt",
"folderCreateFailed": "Ordner konnte nicht erstellt werden",
"renamed": "Element erfolgreich umbenannt",
"renameFailed": "Element konnte nicht umbenannt werden",
"deleted": "Element erfolgreich gelöscht",
"deleteFailed": "Element konnte nicht gelöscht werden",
"downloadStarted": "Download gestartet",
"downloadFailed": "Datei konnte nicht heruntergeladen werden",
"uploaded": "Dateien erfolgreich hochgeladen",
"uploadFailed": "Dateien konnten nicht hochgeladen werden"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"buckets": "Buckets", "buckets": "Buckets",
"explorer": "Explorer",
"users": "Users", "users": "Users",
"policies": "Policies", "policies": "Policies",
"reports": "Reports", "reports": "Reports",
+133
View File
@@ -0,0 +1,133 @@
{
"title": "Storage Explorer",
"tabs": {
"aliases": "Aliases",
"terminal": "Terminal"
},
"aliases": {
"title": "MinIO Aliases",
"addAlias": "Add Alias",
"editAlias": "Edit Alias",
"deleteAlias": "Delete Alias",
"testConnection": "Test Connection",
"columns": {
"name": "Name",
"endpoint": "Endpoint",
"status": "Status",
"actions": "Actions"
},
"status": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown",
"testing": "Testing..."
},
"dialog": {
"title": "Alias Configuration",
"aliasName": "Alias Name",
"endpoint": "Endpoint URL",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"aliasNameHelper": "A unique name for this connection (e.g., production, staging)",
"endpointHelper": "MinIO server URL (e.g., https://minio.example.com)"
},
"empty": {
"title": "No aliases configured",
"message": "Add a MinIO alias to start browsing files"
},
"messages": {
"created": "Alias created successfully",
"updated": "Alias updated successfully",
"deleted": "Alias deleted successfully",
"testSuccess": "Connection successful",
"testFailed": "Connection failed"
}
},
"terminal": {
"title": "MinIO CLI Terminal",
"placeholder": "Enter mc command (e.g., mc ls myminio)",
"clear": "Clear",
"execute": "Execute",
"executing": "Executing...",
"running": "Running...",
"hint": "Use arrow keys to navigate command history",
"welcomeMessage": "Welcome to MinIO CLI Terminal. Type 'mc' commands to interact with your MinIO servers.",
"helpText": "Tip: Use up/down arrow keys to navigate command history"
},
"browser": {
"title": "File Browser",
"selectBucket": "Select Bucket",
"upload": "Upload",
"createFolder": "Create Folder",
"newFolder": "New Folder",
"download": "Download",
"delete": "Delete",
"rename": "Rename",
"copy": "Copy",
"refresh": "Refresh",
"deleteItem": "Delete Item",
"selected": "{{count}} item(s) selected",
"columns": {
"name": "Name",
"size": "Size",
"type": "Type",
"lastModified": "Last Modified",
"actions": "Actions"
},
"types": {
"file": "File",
"folder": "Folder"
},
"empty": {
"title": "No files",
"message": "This folder is empty. Upload files or create a new folder."
},
"errors": {
"missingParams": "Missing alias or bucket parameter"
},
"uploadDialog": {
"title": "Upload Files",
"dropzone": "Drag and drop files here",
"or": "or",
"browse": "Browse Files",
"selectedFiles": "{{count}} file(s) selected",
"uploading": "Uploading..."
},
"createFolderDialog": {
"title": "Create Folder",
"nameLabel": "Folder Name",
"nameHelper": "Enter a name for the new folder",
"errors": {
"required": "Folder name is required",
"invalidChars": "Folder name cannot contain / or \\",
"dotStart": "Folder name cannot start with a dot"
}
},
"renameDialog": {
"title": "Rename",
"newNameLabel": "New Name",
"errors": {
"required": "Name is required",
"same": "New name must be different from current name",
"invalidChars": "Name cannot contain / or \\"
}
},
"deleteConfirm": {
"title": "Delete {{name}}?",
"message": "This action cannot be undone.",
"folderWarning": "This folder and all its contents will be permanently deleted."
},
"messages": {
"folderCreated": "Folder created successfully",
"folderCreateFailed": "Failed to create folder",
"renamed": "Item renamed successfully",
"renameFailed": "Failed to rename item",
"deleted": "Item deleted successfully",
"deleteFailed": "Failed to delete item",
"downloadStarted": "Download started",
"downloadFailed": "Failed to download file",
"uploaded": "Files uploaded successfully",
"uploadFailed": "Failed to upload files"
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import api from './api';
export interface Alias {
alias: string;
URL: string;
accessKey: string;
status: 'connected' | 'disconnected' | 'unknown';
}
export interface AliasInput {
aliasName: string;
endpoint: string;
accessKey: string;
secretKey: string;
}
export interface ConnectionStatus {
status: string;
alias: string;
error?: string;
}
class AliasService {
async listAliases(): Promise<Alias[]> {
const response = await api.get<{ aliases: Alias[]; count: number }>('/aliases');
return response.data.aliases;
}
async getAlias(name: string): Promise<Alias> {
const response = await api.get<Alias>(`/aliases/${name}`);
return response.data;
}
async addAlias(alias: AliasInput): Promise<void> {
await api.post('/aliases', alias);
}
async updateAlias(name: string, alias: Omit<AliasInput, 'aliasName'>): Promise<void> {
await api.put(`/aliases/${name}`, alias);
}
async removeAlias(name: string): Promise<void> {
await api.delete(`/aliases/${name}`);
}
async testConnection(name: string): Promise<ConnectionStatus> {
const response = await api.post<ConnectionStatus>(`/aliases/${name}/test`);
return response.data;
}
}
export default new AliasService();
+123
View File
@@ -0,0 +1,123 @@
import api from './api';
export interface FileEntry {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
type: 'file' | 'folder';
etag?: string | null;
}
export interface ListFilesResponse {
objects: FileEntry[];
prefix: string;
count: number;
}
export interface FileStat {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
contentType: string;
etag: string | null;
metadata: Record<string, string>;
}
export interface PathSize {
path: string;
size: number;
sizeFormatted: string;
objects: number;
}
class FileService {
async listFiles(alias: string, bucket: string, prefix?: string, recursive?: boolean): Promise<FileEntry[]> {
const response = await api.get<ListFilesResponse>(`/browser/${alias}/${bucket}`, {
params: { prefix, recursive },
});
return response.data.objects;
}
async getFileStat(alias: string, bucket: string, path: string): Promise<FileStat> {
const response = await api.get<FileStat>(`/browser/${alias}/${bucket}/stat/${encodeURIComponent(path)}`);
return response.data;
}
async getPathSize(alias: string, bucket: string, path?: string): Promise<PathSize> {
const url = path
? `/browser/${alias}/${bucket}/size/${encodeURIComponent(path)}`
: `/browser/${alias}/${bucket}/size`;
const response = await api.get<PathSize>(url);
return response.data;
}
async uploadFiles(alias: string, bucket: string, files: File[], prefix?: string): Promise<{
uploaded: { name: string; key: string; size: number }[];
failed: { name: string; error: string }[];
count: number;
}> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await api.post(`/browser/${alias}/${bucket}/upload`, formData, {
params: { prefix },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 min for large uploads
});
return response.data;
}
async deleteFile(alias: string, bucket: string, path: string, recursive?: boolean): Promise<void> {
await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
params: { recursive },
});
}
async renameFile(alias: string, bucket: string, source: string, destination: string): Promise<void> {
await api.post(`/browser/${alias}/${bucket}/rename`, {
source,
destination,
});
}
async copyFile(alias: string, bucket: string, source: string, destination: string): Promise<void> {
await api.post(`/browser/${alias}/${bucket}/copy`, {
source,
destination,
});
}
async createFolder(alias: string, bucket: string, folderName: string, prefix?: string): Promise<void> {
await api.post(
`/browser/${alias}/${bucket}/mkdir`,
{ folderName },
{ params: { prefix } }
);
}
async downloadFile(alias: string, bucket: string, path: string): Promise<void> {
const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, {
responseType: 'blob',
timeout: 300000, // 5 min for large downloads
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = path.split('/').pop() || 'download';
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
}
export default new FileService();
+21
View File
@@ -0,0 +1,21 @@
import api from './api';
export interface CommandResponse {
success: boolean;
output: string;
error: string;
exitCode: number;
executionTime: number;
}
class TerminalService {
async executeCommand(command: string, timeout?: number): Promise<CommandResponse> {
const response = await api.post<CommandResponse>('/terminal/execute', {
command,
timeout,
});
return response.data;
}
}
export default new TerminalService();
+235
View File
@@ -0,0 +1,235 @@
import { create } from 'zustand';
import api from '../services/api';
export interface Alias {
alias: string;
URL: string;
accessKey: string;
status: 'connected' | 'disconnected' | 'unknown';
}
export interface FileEntry {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
type: 'file' | 'folder';
etag?: string | null;
contentType?: string | null;
}
interface ExplorerState {
// Aliases
aliases: Alias[];
aliasesLoading: boolean;
aliasesError: string | null;
// File Browser
currentAlias: string | null;
currentBucket: string | null;
currentPath: string;
files: FileEntry[];
filesLoading: boolean;
filesError: string | null;
selectedFiles: string[];
// Actions - Aliases
loadAliases: () => Promise<void>;
addAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
updateAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
removeAlias: (aliasName: string) => Promise<void>;
testConnection: (aliasName: string) => Promise<{ status: string; error?: string }>;
// Actions - File Browser
setCurrentLocation: (alias: string | null, bucket: string | null, path: string) => void;
loadFiles: (alias: string, bucket: string, prefix?: string) => Promise<void>;
uploadFiles: (alias: string, bucket: string, files: File[], prefix?: string) => Promise<void>;
deleteFile: (alias: string, bucket: string, path: string, recursive?: boolean) => Promise<void>;
renameFile: (alias: string, bucket: string, source: string, destination: string) => Promise<void>;
createFolder: (alias: string, bucket: string, folderName: string, prefix?: string) => Promise<void>;
downloadFile: (alias: string, bucket: string, path: string) => Promise<void>;
// Selection
toggleFileSelection: (key: string) => void;
clearSelection: () => void;
selectAll: () => void;
}
export const useExplorerStore = create<ExplorerState>((set, get) => ({
// Initial state
aliases: [],
aliasesLoading: false,
aliasesError: null,
currentAlias: null,
currentBucket: null,
currentPath: '',
files: [],
filesLoading: false,
filesError: null,
selectedFiles: [],
// Alias Actions
loadAliases: async () => {
set({ aliasesLoading: true, aliasesError: null });
try {
const response = await api.get<{ aliases: Alias[] }>('/aliases');
set({ aliases: response.data.aliases, aliasesLoading: false });
} catch (error: any) {
set({
aliasesError: error.response?.data?.message || 'Failed to load aliases',
aliasesLoading: false,
});
throw error;
}
},
addAlias: async (aliasName, endpoint, accessKey, secretKey) => {
const response = await api.post('/aliases', {
aliasName,
endpoint,
accessKey,
secretKey,
});
// Reload aliases after adding
await get().loadAliases();
return response.data;
},
updateAlias: async (aliasName, endpoint, accessKey, secretKey) => {
await api.put(`/aliases/${aliasName}`, {
endpoint,
accessKey,
secretKey,
});
await get().loadAliases();
},
removeAlias: async (aliasName) => {
await api.delete(`/aliases/${aliasName}`);
await get().loadAliases();
},
testConnection: async (aliasName) => {
const response = await api.post<{ status: string; error?: string }>(`/aliases/${aliasName}/test`);
return response.data;
},
// File Browser Actions
setCurrentLocation: (alias, bucket, path) => {
set({
currentAlias: alias,
currentBucket: bucket,
currentPath: path,
selectedFiles: [],
});
},
loadFiles: async (alias, bucket, prefix = '') => {
set({ filesLoading: true, filesError: null, selectedFiles: [] });
try {
const response = await api.get<{ objects: FileEntry[]; prefix: string; count: number }>(
`/browser/${alias}/${bucket}`,
{ params: { prefix } }
);
set({
files: response.data.objects,
currentPath: prefix,
filesLoading: false,
});
} catch (error: any) {
set({
filesError: error.response?.data?.message || 'Failed to load files',
filesLoading: false,
files: [],
});
throw error;
}
},
uploadFiles: async (alias, bucket, files, prefix = '') => {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
await api.post(`/browser/${alias}/${bucket}/upload`, formData, {
params: { prefix },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 min for large uploads
});
// Reload files after upload
await get().loadFiles(alias, bucket, prefix);
},
deleteFile: async (alias, bucket, path, recursive = false) => {
await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
params: { recursive },
});
// Reload files after delete
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
renameFile: async (alias, bucket, source, destination) => {
await api.post(`/browser/${alias}/${bucket}/rename`, {
source,
destination,
});
// Reload files after rename
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
createFolder: async (alias, bucket, folderName, prefix = '') => {
await api.post(
`/browser/${alias}/${bucket}/mkdir`,
{ folderName },
{ params: { prefix } }
);
// Reload files after creating folder
await get().loadFiles(alias, bucket, prefix);
},
downloadFile: async (alias, bucket, path) => {
const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, {
responseType: 'blob',
timeout: 300000, // 5 min for large downloads
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = path.split('/').pop() || 'download';
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// Selection Actions
toggleFileSelection: (key) => {
set((state) => {
const isSelected = state.selectedFiles.includes(key);
return {
selectedFiles: isSelected
? state.selectedFiles.filter((k) => k !== key)
: [...state.selectedFiles, key],
};
});
},
clearSelection: () => {
set({ selectedFiles: [] });
},
selectAll: () => {
set((state) => ({
selectedFiles: state.files.map((f) => f.key),
}));
},
}));
+128
View File
@@ -0,0 +1,128 @@
import { create } from 'zustand';
import api from '../services/api';
export interface OutputEntry {
id: string;
command: string;
output: string;
error: string;
isError: boolean;
timestamp: Date;
executionTime: number;
}
interface TerminalState {
commandHistory: string[];
outputHistory: OutputEntry[];
currentCommand: string;
historyIndex: number;
isExecuting: boolean;
// Actions
executeCommand: (command: string) => Promise<void>;
clearOutput: () => void;
navigateHistory: (direction: 'up' | 'down') => void;
setCurrentCommand: (command: string) => void;
}
export const useTerminalStore = create<TerminalState>((set, get) => ({
commandHistory: [],
outputHistory: [],
currentCommand: '',
historyIndex: -1,
isExecuting: false,
executeCommand: async (command: string) => {
if (!command.trim()) return;
const state = get();
// Add to command history (avoid duplicates at the end)
const newHistory = [...state.commandHistory];
if (newHistory[newHistory.length - 1] !== command) {
newHistory.push(command);
}
set({
isExecuting: true,
currentCommand: '',
commandHistory: newHistory,
historyIndex: -1,
});
try {
const response = await api.post<{
success: boolean;
output: string;
error: string;
exitCode: number;
executionTime: number;
}>('/terminal/execute', { command });
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: response.data.output,
error: response.data.error,
isError: !response.data.success,
timestamp: new Date(),
executionTime: response.data.executionTime,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
} catch (error: any) {
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: '',
error: error.response?.data?.message || error.message || 'Command execution failed',
isError: true,
timestamp: new Date(),
executionTime: 0,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
}
},
clearOutput: () => {
set({ outputHistory: [] });
},
navigateHistory: (direction: 'up' | 'down') => {
const state = get();
const { commandHistory, historyIndex } = state;
if (commandHistory.length === 0) return;
let newIndex: number;
if (direction === 'up') {
newIndex = historyIndex === -1
? commandHistory.length - 1
: Math.max(0, historyIndex - 1);
} else {
newIndex = historyIndex === -1
? -1
: Math.min(commandHistory.length - 1, historyIndex + 1);
if (historyIndex === commandHistory.length - 1) {
newIndex = -1;
}
}
set({
historyIndex: newIndex,
currentCommand: newIndex === -1 ? '' : commandHistory[newIndex],
});
},
setCurrentCommand: (command: string) => {
set({ currentCommand: command, historyIndex: -1 });
},
}));