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:
@@ -33,7 +33,8 @@
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.8",
|
||||
"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": {
|
||||
"eslint": "^8.56.0",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -18,6 +18,9 @@ const bucketRoutes = require('./api/buckets');
|
||||
const userRoutes = require('./api/users');
|
||||
const policyRoutes = require('./api/policies');
|
||||
const reportRoutes = require('./api/reports');
|
||||
const terminalRoutes = require('./api/terminal');
|
||||
const aliasRoutes = require('./api/aliases');
|
||||
const browserRoutes = require('./api/browser');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
@@ -111,6 +114,22 @@ const authLimiter = rateLimit({
|
||||
|
||||
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
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
@@ -126,6 +145,9 @@ app.use('/api/buckets', bucketRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/policies', policyRoutes);
|
||||
app.use('/api/reports', reportRoutes);
|
||||
app.use('/api/terminal', terminalRoutes);
|
||||
app.use('/api/aliases', aliasRoutes);
|
||||
app.use('/api/browser', browserRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -483,7 +483,328 @@ class MinIOService {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets';
|
||||
import Users from './components/Users/Users';
|
||||
import Policies from './components/Policies/Policies';
|
||||
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';
|
||||
|
||||
function App() {
|
||||
@@ -30,6 +34,13 @@ function App() {
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<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="/policies" element={<Policies />} />
|
||||
<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;
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Storage as StorageIcon,
|
||||
FolderOpen as FolderOpenIcon,
|
||||
People as PeopleIcon,
|
||||
Policy as PolicyIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
@@ -55,6 +56,7 @@ const Layout: React.FC = () => {
|
||||
const navItems: NavItem[] = [
|
||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' },
|
||||
{ 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: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' },
|
||||
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' },
|
||||
@@ -224,7 +226,7 @@ const Layout: React.FC = () => {
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
|
||||
@@ -8,12 +8,14 @@ import enDashboard from './locales/en/dashboard.json';
|
||||
import enQuickWizard from './locales/en/quickWizard.json';
|
||||
import enReports from './locales/en/reports.json';
|
||||
import enErrors from './locales/en/errors.json';
|
||||
import enExplorer from './locales/en/explorer.json';
|
||||
|
||||
import deCommon from './locales/de/common.json';
|
||||
import deDashboard from './locales/de/dashboard.json';
|
||||
import deQuickWizard from './locales/de/quickWizard.json';
|
||||
import deReports from './locales/de/reports.json';
|
||||
import deErrors from './locales/de/errors.json';
|
||||
import deExplorer from './locales/de/explorer.json';
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
@@ -22,6 +24,7 @@ const resources = {
|
||||
quickWizard: enQuickWizard,
|
||||
reports: enReports,
|
||||
errors: enErrors,
|
||||
explorer: enExplorer,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
@@ -29,6 +32,7 @@ const resources = {
|
||||
quickWizard: deQuickWizard,
|
||||
reports: deReports,
|
||||
errors: deErrors,
|
||||
explorer: deExplorer,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,7 +43,7 @@ i18n
|
||||
resources,
|
||||
lng: 'de', // Default to German
|
||||
fallbackLng: 'en',
|
||||
ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors'],
|
||||
ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors', 'explorer'],
|
||||
defaultNS: 'common',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes values
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"navigation": {
|
||||
"dashboard": "Übersicht",
|
||||
"buckets": "Buckets",
|
||||
"explorer": "Explorer",
|
||||
"users": "Benutzer",
|
||||
"policies": "Richtlinien",
|
||||
"reports": "Berichte",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"buckets": "Buckets",
|
||||
"explorer": "Explorer",
|
||||
"users": "Users",
|
||||
"policies": "Policies",
|
||||
"reports": "Reports",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user