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",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^4.7.1"
|
"winston-daily-rotate-file": "^4.7.1",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
|
|||||||
@@ -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 userRoutes = require('./api/users');
|
||||||
const policyRoutes = require('./api/policies');
|
const policyRoutes = require('./api/policies');
|
||||||
const reportRoutes = require('./api/reports');
|
const reportRoutes = require('./api/reports');
|
||||||
|
const terminalRoutes = require('./api/terminal');
|
||||||
|
const aliasRoutes = require('./api/aliases');
|
||||||
|
const browserRoutes = require('./api/browser');
|
||||||
|
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -111,6 +114,22 @@ const authLimiter = rateLimit({
|
|||||||
|
|
||||||
app.use('/api/auth/login', authLimiter);
|
app.use('/api/auth/login', authLimiter);
|
||||||
|
|
||||||
|
// Rate limiting for terminal commands (30 per minute)
|
||||||
|
const terminalLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
message: 'Too many commands, please wait.',
|
||||||
|
});
|
||||||
|
app.use('/api/terminal', terminalLimiter);
|
||||||
|
|
||||||
|
// Rate limiting for browser operations (60 per minute)
|
||||||
|
const browserLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
message: 'Too many file operations, please slow down.',
|
||||||
|
});
|
||||||
|
app.use('/api/browser', browserLimiter);
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -126,6 +145,9 @@ app.use('/api/buckets', bucketRoutes);
|
|||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/policies', policyRoutes);
|
app.use('/api/policies', policyRoutes);
|
||||||
app.use('/api/reports', reportRoutes);
|
app.use('/api/reports', reportRoutes);
|
||||||
|
app.use('/api/terminal', terminalRoutes);
|
||||||
|
app.use('/api/aliases', aliasRoutes);
|
||||||
|
app.use('/api/browser', browserRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
|
|||||||
@@ -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);
|
return name && name.length >= 1 && name.length <= 32 && regex.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Terminal Methods (Raw Command Execution)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async executeRawCommand(command, options = {}) {
|
||||||
|
const { timeout = 30000 } = options;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Security: Only allow mc commands
|
||||||
|
if (!command.trim().startsWith('mc ')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: 'Only mc commands are allowed',
|
||||||
|
exitCode: 1,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Block shell operators
|
||||||
|
const blockedPatterns = [/;/, /\|/, /`/, /\$\(/, />>?/, /<</, /&&/, /\|\|/];
|
||||||
|
for (const pattern of blockedPatterns) {
|
||||||
|
if (pattern.test(command)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: 'Command contains forbidden shell operators',
|
||||||
|
exitCode: 1,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`Executing raw command: ${command}`);
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execAsync(command, {
|
||||||
|
timeout,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
env: { ...process.env, MC_NO_COLOR: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: stdout || '',
|
||||||
|
error: stderr || '',
|
||||||
|
exitCode: 0,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: error.stdout || '',
|
||||||
|
error: error.stderr || error.message,
|
||||||
|
exitCode: error.code || 1,
|
||||||
|
executionTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Extended Alias Management Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getAlias(aliasName) {
|
||||||
|
const aliases = await this.listAliases();
|
||||||
|
const alias = aliases.find(a => a.alias === aliasName);
|
||||||
|
if (!alias) {
|
||||||
|
throw new AppError('Alias not found', 404);
|
||||||
|
}
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAlias(aliasName, endpoint, accessKey, secretKey) {
|
||||||
|
if (!this.isValidAliasName(aliasName)) {
|
||||||
|
throw new AppError('Invalid alias name', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mc alias set overwrites existing alias
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc alias set ${aliasName} ${endpoint} ${accessKey} ${secretKey}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const testResult = await this.testConnection(aliasName);
|
||||||
|
return {
|
||||||
|
message: 'Alias updated successfully',
|
||||||
|
aliasName,
|
||||||
|
connectionStatus: testResult.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAlias(aliasName) {
|
||||||
|
if (!this.isValidAliasName(aliasName)) {
|
||||||
|
throw new AppError('Invalid alias name', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.executeCommand(`mc alias remove ${aliasName}`);
|
||||||
|
return { message: 'Alias removed successfully', aliasName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// File Browser Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
validatePath(pathStr) {
|
||||||
|
if (!pathStr) return true;
|
||||||
|
|
||||||
|
const decoded = decodeURIComponent(pathStr);
|
||||||
|
|
||||||
|
// Path traversal prevention
|
||||||
|
if (decoded.includes('..')) {
|
||||||
|
throw new AppError('Path traversal not allowed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null byte injection prevention
|
||||||
|
if (decoded.includes('\0')) {
|
||||||
|
throw new AppError('Invalid path', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command injection prevention in paths
|
||||||
|
const dangerousPatterns = [';', '|', '`', '$', '>', '<', '&'];
|
||||||
|
for (const pattern of dangerousPatterns) {
|
||||||
|
if (decoded.includes(pattern)) {
|
||||||
|
throw new AppError('Invalid characters in path', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listObjects(alias, bucket, options = {}) {
|
||||||
|
const { prefix = '', recursive = false } = options;
|
||||||
|
|
||||||
|
// Validate path security
|
||||||
|
this.validatePath(prefix);
|
||||||
|
|
||||||
|
const targetPath = prefix
|
||||||
|
? `${alias}/${bucket}/${prefix}`
|
||||||
|
: `${alias}/${bucket}`;
|
||||||
|
const recursiveFlag = recursive ? '--recursive' : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await this.executeCommand(
|
||||||
|
`mc ls "${targetPath}" ${recursiveFlag} --json`,
|
||||||
|
{ parseJson: true, timeout: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let objects = Array.isArray(output) ? output : output ? [output] : [];
|
||||||
|
|
||||||
|
// Transform results
|
||||||
|
objects = objects.map(obj => ({
|
||||||
|
key: obj.key || '',
|
||||||
|
name: (obj.key || '').split('/').filter(Boolean).pop() || obj.key || '',
|
||||||
|
size: obj.size || 0,
|
||||||
|
sizeFormatted: this.formatBytes(obj.size || 0),
|
||||||
|
lastModified: obj.lastModified || null,
|
||||||
|
type: (obj.key || '').endsWith('/') ? 'folder' : 'file',
|
||||||
|
etag: obj.etag || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
prefix,
|
||||||
|
count: objects.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Return empty list if path doesn't exist or is empty
|
||||||
|
if (error.message && error.message.includes('not found')) {
|
||||||
|
return { objects: [], prefix, count: 0 };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObjectStat(alias, bucket, objectPath) {
|
||||||
|
this.validatePath(objectPath);
|
||||||
|
|
||||||
|
const output = await this.executeCommand(
|
||||||
|
`mc stat "${alias}/${bucket}/${objectPath}" --json`,
|
||||||
|
{ parseJson: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: objectPath,
|
||||||
|
name: objectPath.split('/').filter(Boolean).pop() || objectPath,
|
||||||
|
size: output.size || 0,
|
||||||
|
sizeFormatted: this.formatBytes(output.size || 0),
|
||||||
|
lastModified: output.lastModified || null,
|
||||||
|
contentType: output.metadata?.['content-type'] || 'application/octet-stream',
|
||||||
|
etag: output.etag || null,
|
||||||
|
metadata: output.metadata || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadObject(alias, bucket, objectPath) {
|
||||||
|
this.validatePath(objectPath);
|
||||||
|
|
||||||
|
// Generate unique temp file path
|
||||||
|
const fileName = objectPath.split('/').filter(Boolean).pop() || 'download';
|
||||||
|
const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
const tempFilePath = path.join(
|
||||||
|
this.tempDir,
|
||||||
|
`${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc cp "${alias}/${bucket}/${objectPath}" "${tempFilePath}"`,
|
||||||
|
{ timeout: 300000 } // 5 min for large files
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: tempFilePath,
|
||||||
|
fileName,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.unlink(tempFilePath).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadObject(alias, bucket, objectPath, localFilePath) {
|
||||||
|
this.validatePath(objectPath);
|
||||||
|
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc cp "${localFilePath}" "${alias}/${bucket}/${objectPath}"`,
|
||||||
|
{ timeout: 300000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { key: objectPath, message: 'File uploaded successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteObject(alias, bucket, objectPath, recursive = false) {
|
||||||
|
this.validatePath(objectPath);
|
||||||
|
|
||||||
|
const recursiveFlag = recursive ? '--recursive --force' : '';
|
||||||
|
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc rm "${alias}/${bucket}/${objectPath}" ${recursiveFlag}`,
|
||||||
|
{ timeout: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return { message: 'Object deleted successfully', key: objectPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameObject(alias, bucket, sourcePath, destPath) {
|
||||||
|
this.validatePath(sourcePath);
|
||||||
|
this.validatePath(destPath);
|
||||||
|
|
||||||
|
// mc mv for rename
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc mv "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
|
||||||
|
{ timeout: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Object renamed successfully',
|
||||||
|
source: sourcePath,
|
||||||
|
destination: destPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyObject(alias, bucket, sourcePath, destPath) {
|
||||||
|
this.validatePath(sourcePath);
|
||||||
|
this.validatePath(destPath);
|
||||||
|
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc cp "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
|
||||||
|
{ timeout: 60000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Object copied successfully',
|
||||||
|
source: sourcePath,
|
||||||
|
destination: destPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFolder(alias, bucket, folderPath) {
|
||||||
|
this.validatePath(folderPath);
|
||||||
|
|
||||||
|
// Ensure path ends with /
|
||||||
|
const normalizedPath = folderPath.endsWith('/') ? folderPath : `${folderPath}/`;
|
||||||
|
|
||||||
|
// Create empty object to represent folder (.keep file)
|
||||||
|
const emptyFile = path.join(this.tempDir, `.empty-${Date.now()}`);
|
||||||
|
await fs.writeFile(emptyFile, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.executeCommand(
|
||||||
|
`mc cp "${emptyFile}" "${alias}/${bucket}/${normalizedPath}.keep"`,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(emptyFile).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Folder created successfully', path: normalizedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPathSize(alias, bucket, objectPath = '') {
|
||||||
|
this.validatePath(objectPath);
|
||||||
|
|
||||||
|
const targetPath = objectPath
|
||||||
|
? `${alias}/${bucket}/${objectPath}`
|
||||||
|
: `${alias}/${bucket}`;
|
||||||
|
|
||||||
|
const output = await this.executeCommand(
|
||||||
|
`mc du "${targetPath}" --json`,
|
||||||
|
{ parseJson: true, timeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: objectPath || '/',
|
||||||
|
size: output.size || 0,
|
||||||
|
sizeFormatted: this.formatBytes(output.size || 0),
|
||||||
|
objects: output.objects || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
// Utility Methods
|
// Utility Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
formatBytes(bytes) {
|
formatBytes(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets';
|
|||||||
import Users from './components/Users/Users';
|
import Users from './components/Users/Users';
|
||||||
import Policies from './components/Policies/Policies';
|
import Policies from './components/Policies/Policies';
|
||||||
import Reports from './components/Reports/Reports';
|
import Reports from './components/Reports/Reports';
|
||||||
|
import Explorer from './components/Explorer/Explorer';
|
||||||
|
import Aliases from './components/Explorer/Aliases/Aliases';
|
||||||
|
import Terminal from './components/Explorer/Terminal/Terminal';
|
||||||
|
import FileBrowser from './components/Explorer/FileBrowser/FileBrowser';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -30,6 +34,13 @@ function App() {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/buckets" element={<Buckets />} />
|
<Route path="/buckets" element={<Buckets />} />
|
||||||
|
<Route path="/explorer" element={<Explorer />}>
|
||||||
|
<Route index element={<Navigate to="aliases" replace />} />
|
||||||
|
<Route path="aliases" element={<Aliases />} />
|
||||||
|
<Route path="terminal" element={<Terminal />} />
|
||||||
|
<Route path="browse/:alias/:bucket" element={<FileBrowser />} />
|
||||||
|
<Route path="browse/:alias/:bucket/*" element={<FileBrowser />} />
|
||||||
|
</Route>
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/policies" element={<Policies />} />
|
<Route path="/policies" element={<Policies />} />
|
||||||
<Route path="/reports" element={<Reports />} />
|
<Route path="/reports" element={<Reports />} />
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useExplorerStore, Alias } from '../../../store/explorerStore';
|
||||||
|
|
||||||
|
interface AliasDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
alias: Alias | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AliasDialog: React.FC<AliasDialogProps> = ({ open, alias, onClose, onSuccess }) => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const { addAlias, updateAlias } = useExplorerStore();
|
||||||
|
|
||||||
|
const [aliasName, setAliasName] = useState('');
|
||||||
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [accessKey, setAccessKey] = useState('');
|
||||||
|
const [secretKey, setSecretKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const isEdit = !!alias;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (alias) {
|
||||||
|
setAliasName(alias.alias);
|
||||||
|
setEndpoint(alias.URL);
|
||||||
|
setAccessKey('');
|
||||||
|
setSecretKey('');
|
||||||
|
} else {
|
||||||
|
setAliasName('');
|
||||||
|
setEndpoint('');
|
||||||
|
setAccessKey('');
|
||||||
|
setSecretKey('');
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [open, alias]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!aliasName.trim()) {
|
||||||
|
setError('Alias name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!endpoint.trim()) {
|
||||||
|
setError('Endpoint is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!accessKey.trim()) {
|
||||||
|
setError('Access key is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!secretKey.trim()) {
|
||||||
|
setError('Secret key is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateAlias(aliasName, endpoint, accessKey, secretKey);
|
||||||
|
} else {
|
||||||
|
await addAlias(aliasName, endpoint, accessKey, secretKey);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || err.message || 'Operation failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogTitle>
|
||||||
|
{t('explorer:aliases.dialog.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('explorer:aliases.dialog.aliasName')}
|
||||||
|
value={aliasName}
|
||||||
|
onChange={(e) => setAliasName(e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
helperText={t('explorer:aliases.dialog.aliasNameHelper')}
|
||||||
|
inputProps={{ pattern: '[a-zA-Z0-9_-]+' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('explorer:aliases.dialog.endpoint')}
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
placeholder="https://minio.example.com"
|
||||||
|
helperText={t('explorer:aliases.dialog.endpointHelper')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('explorer:aliases.dialog.accessKey')}
|
||||||
|
value={accessKey}
|
||||||
|
onChange={(e) => setAccessKey(e.target.value)}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('explorer:aliases.dialog.secretKey')}
|
||||||
|
type="password"
|
||||||
|
value={secretKey}
|
||||||
|
onChange={(e) => setSecretKey(e.target.value)}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={loading}>
|
||||||
|
{t('common:actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||||
|
>
|
||||||
|
{isEdit ? t('common:actions.save') : t('common:actions.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AliasDialog;
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Typography,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
NetworkCheck as TestIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useExplorerStore, Alias } from '../../../store/explorerStore';
|
||||||
|
import AliasDialog from './AliasDialog';
|
||||||
|
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||||
|
import api from '../../../services/api';
|
||||||
|
|
||||||
|
const Aliases: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { aliases, aliasesLoading, loadAliases, removeAlias, testConnection } = useExplorerStore();
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingAlias, setEditingAlias] = useState<Alias | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [aliasToDelete, setAliasToDelete] = useState<string | null>(null);
|
||||||
|
const [testingAlias, setTestingAlias] = useState<string | null>(null);
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAliases();
|
||||||
|
}, [loadAliases]);
|
||||||
|
|
||||||
|
const handleAddAlias = () => {
|
||||||
|
setEditingAlias(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAlias = (alias: Alias) => {
|
||||||
|
setEditingAlias(alias);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (aliasName: string) => {
|
||||||
|
setAliasToDelete(aliasName);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (aliasToDelete) {
|
||||||
|
try {
|
||||||
|
await removeAlias(aliasToDelete);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:aliases.messages.deleted'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.response?.data?.message || 'Delete failed',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setAliasToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async (aliasName: string) => {
|
||||||
|
setTestingAlias(aliasName);
|
||||||
|
try {
|
||||||
|
const result = await testConnection(aliasName);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: result.status === 'connected'
|
||||||
|
? t('explorer:aliases.messages.testSuccess')
|
||||||
|
: t('explorer:aliases.messages.testFailed'),
|
||||||
|
severity: result.status === 'connected' ? 'success' : 'error',
|
||||||
|
});
|
||||||
|
// Reload to update status
|
||||||
|
loadAliases();
|
||||||
|
} catch (error) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:aliases.messages.testFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTestingAlias(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAliasClick = async (alias: Alias) => {
|
||||||
|
// Fetch buckets for this alias and navigate to first one, or show bucket picker
|
||||||
|
try {
|
||||||
|
const response = await api.get<{ buckets: { key: string }[] }>('/buckets');
|
||||||
|
if (response.data.buckets && response.data.buckets.length > 0) {
|
||||||
|
// Navigate to first bucket
|
||||||
|
navigate(`/explorer/browse/${alias.alias}/${response.data.buckets[0].key}`);
|
||||||
|
} else {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: 'No buckets found for this alias',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.response?.data?.message || 'Failed to load buckets',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSuccess = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
loadAliases();
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: editingAlias
|
||||||
|
? t('explorer:aliases.messages.updated')
|
||||||
|
: t('explorer:aliases.messages.created'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return 'success';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (aliasesLoading && aliases.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h6">{t('explorer:aliases.title')}</Typography>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => loadAliases()}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
{t('common:actions.refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={handleAddAlias}
|
||||||
|
>
|
||||||
|
{t('explorer:aliases.addAlias')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t('explorer:aliases.columns.name')}</TableCell>
|
||||||
|
<TableCell>{t('explorer:aliases.columns.endpoint')}</TableCell>
|
||||||
|
<TableCell>{t('explorer:aliases.columns.status')}</TableCell>
|
||||||
|
<TableCell align="right">{t('explorer:aliases.columns.actions')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{aliases.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} align="center">
|
||||||
|
<Box py={4}>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{t('explorer:aliases.empty.title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{t('explorer:aliases.empty.message')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
aliases.map((alias) => (
|
||||||
|
<TableRow
|
||||||
|
key={alias.alias}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleAliasClick(alias)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body1" fontWeight="medium">
|
||||||
|
{alias.alias}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{alias.URL}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={t(`explorer:aliases.status.${alias.status}`)}
|
||||||
|
color={getStatusColor(alias.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Tooltip title={t('explorer:aliases.testConnection')}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleTestConnection(alias.alias)}
|
||||||
|
disabled={testingAlias === alias.alias}
|
||||||
|
>
|
||||||
|
{testingAlias === alias.alias ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
<TestIcon />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('explorer:aliases.editAlias')}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditAlias(alias)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('explorer:aliases.deleteAlias')}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleDeleteClick(alias.alias)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<AliasDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
alias={editingAlias}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
onSuccess={handleDialogSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
title={t('explorer:aliases.deleteAlias')}
|
||||||
|
message={t('common:confirmDialog.deleteMessage', { item: aliasToDelete })}
|
||||||
|
confirmText={t('common:actions.delete')}
|
||||||
|
confirmColor="error"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setDeleteDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Aliases;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Box, Typography, Tabs, Tab, Paper } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Storage as StorageIcon,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Explorer: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['explorer']);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Determine current tab based on URL
|
||||||
|
const getCurrentTab = () => {
|
||||||
|
if (location.pathname.includes('/terminal')) return 1;
|
||||||
|
if (location.pathname.includes('/browse')) return -1; // Hide tabs when browsing
|
||||||
|
return 0; // aliases
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTab = getCurrentTab();
|
||||||
|
|
||||||
|
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
if (newValue === 0) navigate('/explorer/aliases');
|
||||||
|
else if (newValue === 1) navigate('/explorer/terminal');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ mb: 3 }}>
|
||||||
|
{t('explorer:title')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{currentTab !== -1 && (
|
||||||
|
<Paper sx={{ mb: 3 }}>
|
||||||
|
<Tabs
|
||||||
|
value={currentTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
indicatorColor="primary"
|
||||||
|
textColor="primary"
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
icon={<StorageIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
label={t('explorer:tabs.aliases')}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<TerminalIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
label={t('explorer:tabs.terminal')}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Explorer;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Breadcrumbs, Link, Typography, Box } from '@mui/material';
|
||||||
|
import {
|
||||||
|
NavigateNext as NavigateNextIcon,
|
||||||
|
Storage as StorageIcon,
|
||||||
|
Home as HomeIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
interface BreadcrumbNavProps {
|
||||||
|
alias: string;
|
||||||
|
bucket: string;
|
||||||
|
path: string;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadcrumbNav: React.FC<BreadcrumbNavProps> = ({
|
||||||
|
alias,
|
||||||
|
bucket,
|
||||||
|
path,
|
||||||
|
onNavigate,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const pathParts = path ? path.split('/').filter(Boolean) : [];
|
||||||
|
|
||||||
|
const handleClick = (index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
// Navigate to bucket root
|
||||||
|
onNavigate('');
|
||||||
|
} else {
|
||||||
|
// Navigate to specific path
|
||||||
|
const newPath = pathParts.slice(0, index + 1).join('/');
|
||||||
|
onNavigate(newPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
>
|
||||||
|
{/* Alias link - goes back to aliases list */}
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body1"
|
||||||
|
onClick={() => window.location.href = '/explorer/aliases'}
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<HomeIcon sx={{ mr: 0.5 }} fontSize="small" />
|
||||||
|
{alias}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Bucket link */}
|
||||||
|
{pathParts.length > 0 ? (
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
variant="body1"
|
||||||
|
onClick={() => handleClick(-1)}
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
|
||||||
|
{bucket}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
color="text.primary"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
|
||||||
|
{bucket}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Path parts */}
|
||||||
|
{pathParts.map((part, index) => {
|
||||||
|
const isLast = index === pathParts.length - 1;
|
||||||
|
return isLast ? (
|
||||||
|
<Typography key={index} color="text.primary">
|
||||||
|
{part}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
component="button"
|
||||||
|
variant="body1"
|
||||||
|
onClick={() => handleClick(index)}
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Breadcrumbs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BreadcrumbNav;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface CreateFolderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateFolderDialog: React.FC<CreateFolderDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError(t('explorer:browser.createFolderDialog.errors.required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate folder name
|
||||||
|
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
|
||||||
|
setError(t('explorer:browser.createFolderDialog.errors.invalidChars'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.startsWith('.')) {
|
||||||
|
setError(t('explorer:browser.createFolderDialog.errors.dotStart'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm(trimmedName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogTitle>{t('explorer:browser.createFolderDialog.title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label={t('explorer:browser.createFolderDialog.nameLabel')}
|
||||||
|
fullWidth
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
error={!!error}
|
||||||
|
helperText={error || t('explorer:browser.createFolderDialog.nameHelper')}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
|
||||||
|
<Button type="submit" variant="contained">
|
||||||
|
{t('common:actions.create')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateFolderDialog;
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
|
Snackbar,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Toolbar,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Folder as FolderIcon,
|
||||||
|
InsertDriveFile as FileIcon,
|
||||||
|
Download as DownloadIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
CreateNewFolder as CreateFolderIcon,
|
||||||
|
CloudUpload as UploadIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
ArrowBack as BackIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useExplorerStore, FileEntry } from '../../../store/explorerStore';
|
||||||
|
import BreadcrumbNav from './BreadcrumbNav';
|
||||||
|
import UploadDialog from './UploadDialog';
|
||||||
|
import CreateFolderDialog from './CreateFolderDialog';
|
||||||
|
import RenameDialog from './RenameDialog';
|
||||||
|
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||||
|
|
||||||
|
const FileBrowser: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const { alias, bucket, '*': pathParam } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
filesLoading,
|
||||||
|
loadFiles,
|
||||||
|
downloadFile,
|
||||||
|
deleteFile,
|
||||||
|
renameFile,
|
||||||
|
createFolder,
|
||||||
|
uploadFiles,
|
||||||
|
} = useExplorerStore();
|
||||||
|
|
||||||
|
const currentPath = pathParam || '';
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
|
const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);
|
||||||
|
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [itemToRename, setItemToRename] = useState<FileEntry | null>(null);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
|
||||||
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (alias && bucket) {
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
setSelected([]);
|
||||||
|
}
|
||||||
|
}, [alias, bucket, currentPath, loadFiles]);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback((path: string) => {
|
||||||
|
if (alias && bucket) {
|
||||||
|
const newPath = path ? `/explorer/browse/${alias}/${bucket}/${path}` : `/explorer/browse/${alias}/${bucket}`;
|
||||||
|
navigate(newPath);
|
||||||
|
}
|
||||||
|
}, [alias, bucket, navigate]);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback((item: FileEntry) => {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${item.name}` : item.name;
|
||||||
|
handleNavigate(newPath);
|
||||||
|
}
|
||||||
|
}, [currentPath, handleNavigate]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (currentPath) {
|
||||||
|
const parts = currentPath.split('/');
|
||||||
|
parts.pop();
|
||||||
|
handleNavigate(parts.join('/'));
|
||||||
|
} else {
|
||||||
|
navigate('/explorer/aliases');
|
||||||
|
}
|
||||||
|
}, [currentPath, handleNavigate, navigate]);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelected(files.map((f) => f.key));
|
||||||
|
} else {
|
||||||
|
setSelected([]);
|
||||||
|
}
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((key: string) => {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async (item: FileEntry) => {
|
||||||
|
if (!alias || !bucket) return;
|
||||||
|
try {
|
||||||
|
await downloadFile(alias, bucket, item.key);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.downloadStarted'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.downloadFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [alias, bucket, downloadFile, t]);
|
||||||
|
|
||||||
|
const handleRenameClick = useCallback((item: FileEntry) => {
|
||||||
|
setItemToRename(item);
|
||||||
|
setRenameDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameConfirm = useCallback(async (newName: string) => {
|
||||||
|
if (!alias || !bucket || !itemToRename) return;
|
||||||
|
try {
|
||||||
|
await renameFile(alias, bucket, itemToRename.key, newName);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.renamed'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.renameFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRenameDialogOpen(false);
|
||||||
|
setItemToRename(null);
|
||||||
|
}, [alias, bucket, itemToRename, renameFile, loadFiles, currentPath, t]);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((key: string) => {
|
||||||
|
setItemToDelete(key);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
if (!alias || !bucket || !itemToDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteFile(alias, bucket, itemToDelete);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.deleted'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.deleteFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
}, [alias, bucket, itemToDelete, deleteFile, loadFiles, currentPath, t]);
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
if (!alias || !bucket || selected.length === 0) return;
|
||||||
|
try {
|
||||||
|
for (const key of selected) {
|
||||||
|
await deleteFile(alias, bucket, key);
|
||||||
|
}
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.deleted'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
setSelected([]);
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.deleteFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [alias, bucket, selected, deleteFile, loadFiles, currentPath, t]);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(async (name: string) => {
|
||||||
|
if (!alias || !bucket) return;
|
||||||
|
try {
|
||||||
|
const folderPath = currentPath ? `${currentPath}/${name}` : name;
|
||||||
|
await createFolder(alias, bucket, folderPath);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.folderCreated'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.folderCreateFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setCreateFolderDialogOpen(false);
|
||||||
|
}, [alias, bucket, currentPath, createFolder, loadFiles, t]);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||||
|
if (!alias || !bucket) return;
|
||||||
|
try {
|
||||||
|
await uploadFiles(alias, bucket, uploadedFiles, currentPath);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: t('explorer:browser.messages.uploaded'),
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
loadFiles(alias, bucket, currentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.message || t('explorer:browser.messages.uploadFailed'),
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setUploadDialogOpen(false);
|
||||||
|
}, [alias, bucket, currentPath, uploadFiles, loadFiles, t]);
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '-';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string | null): string => {
|
||||||
|
if (!date) return '-';
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!alias || !bucket) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
|
||||||
|
<Typography color="error">{t('explorer:browser.errors.missingParams')}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Breadcrumb navigation */}
|
||||||
|
<BreadcrumbNav
|
||||||
|
alias={alias}
|
||||||
|
bucket={bucket}
|
||||||
|
path={currentPath}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
pl: { sm: 2 },
|
||||||
|
pr: { xs: 1, sm: 1 },
|
||||||
|
bgcolor: selected.length > 0 ? 'action.selected' : 'transparent',
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ flex: '1 1 100%' }} color="inherit" variant="subtitle1">
|
||||||
|
{t('explorer:browser.selected', { count: selected.length })}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={t('common:actions.delete')}>
|
||||||
|
<IconButton onClick={handleBulkDelete} color="error">
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tooltip title={t('common:actions.back')}>
|
||||||
|
<IconButton onClick={handleBack} sx={{ mr: 1 }}>
|
||||||
|
<BackIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Typography sx={{ flex: '1 1 100%' }} variant="h6">
|
||||||
|
{bucket}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={t('common:actions.refresh')}>
|
||||||
|
<IconButton onClick={() => loadFiles(alias, bucket, currentPath)}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('explorer:browser.createFolder')}>
|
||||||
|
<IconButton onClick={() => setCreateFolderDialogOpen(true)}>
|
||||||
|
<CreateFolderIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
onClick={() => setUploadDialogOpen(true)}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
{t('explorer:browser.upload')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{/* File table */}
|
||||||
|
{filesLoading ? (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={selected.length > 0 && selected.length < files.length}
|
||||||
|
checked={files.length > 0 && selected.length === files.length}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t('explorer:browser.columns.name')}</TableCell>
|
||||||
|
<TableCell>{t('explorer:browser.columns.size')}</TableCell>
|
||||||
|
<TableCell>{t('explorer:browser.columns.type')}</TableCell>
|
||||||
|
<TableCell>{t('explorer:browser.columns.lastModified')}</TableCell>
|
||||||
|
<TableCell align="right">{t('explorer:browser.columns.actions')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center">
|
||||||
|
<Box py={4}>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{t('explorer:browser.empty.title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{t('explorer:browser.empty.message')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
files.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.key}
|
||||||
|
hover
|
||||||
|
sx={{ cursor: item.type === 'folder' ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(item.key)}
|
||||||
|
onChange={() => handleSelect(item.key)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{item.type === 'folder' ? (
|
||||||
|
<FolderIcon color="primary" />
|
||||||
|
) : (
|
||||||
|
<FileIcon color="action" />
|
||||||
|
)}
|
||||||
|
<Typography variant="body2">{item.name}</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatSize(item.size)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{item.type === 'folder' ? t('explorer:browser.types.folder') : (item.contentType || t('explorer:browser.types.file'))}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(item.lastModified)}</TableCell>
|
||||||
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{item.type !== 'folder' && (
|
||||||
|
<Tooltip title={t('explorer:browser.download')}>
|
||||||
|
<IconButton size="small" onClick={() => handleDownload(item)}>
|
||||||
|
<DownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title={t('explorer:browser.rename')}>
|
||||||
|
<IconButton size="small" onClick={() => handleRenameClick(item)}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('common:actions.delete')}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleDeleteClick(item.key)}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<UploadDialog
|
||||||
|
open={uploadDialogOpen}
|
||||||
|
onClose={() => setUploadDialogOpen(false)}
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateFolderDialog
|
||||||
|
open={createFolderDialogOpen}
|
||||||
|
onClose={() => setCreateFolderDialogOpen(false)}
|
||||||
|
onConfirm={handleCreateFolder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameDialog
|
||||||
|
open={renameDialogOpen}
|
||||||
|
currentName={itemToRename?.name || ''}
|
||||||
|
onClose={() => {
|
||||||
|
setRenameDialogOpen(false);
|
||||||
|
setItemToRename(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleRenameConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
title={t('explorer:browser.deleteItem')}
|
||||||
|
message={t('common:confirmDialog.deleteMessage', { item: itemToDelete?.split('/').pop() || '' })}
|
||||||
|
confirmText={t('common:actions.delete')}
|
||||||
|
confirmColor="error"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setItemToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbar.open}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||||
|
>
|
||||||
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
|
||||||
|
{snackbar.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileBrowser;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface RenameDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
currentName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (newName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenameDialog: React.FC<RenameDialogProps> = ({
|
||||||
|
open,
|
||||||
|
currentName,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(currentName);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [open, currentName]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError(t('explorer:browser.renameDialog.errors.required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName === currentName) {
|
||||||
|
setError(t('explorer:browser.renameDialog.errors.same'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
|
||||||
|
setError(t('explorer:browser.renameDialog.errors.invalidChars'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm(trimmedName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogTitle>{t('explorer:browser.renameDialog.title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label={t('explorer:browser.renameDialog.newNameLabel')}
|
||||||
|
fullWidth
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
error={!!error}
|
||||||
|
helperText={error}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
|
||||||
|
<Button type="submit" variant="contained">
|
||||||
|
{t('explorer:browser.rename')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RenameDialog;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CloudUpload as UploadIcon,
|
||||||
|
InsertDriveFile as FileIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface UploadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpload: (files: File[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, onUpload }) => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
|
setFiles((prev) => [...prev, ...droppedFiles]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const selectedFiles = Array.from(e.target.files);
|
||||||
|
setFiles((prev) => [...prev, ...selectedFiles]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveFile = useCallback((index: number) => {
|
||||||
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async () => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await onUpload(files);
|
||||||
|
setFiles([]);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [files, onUpload]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (!uploading) {
|
||||||
|
setFiles([]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [uploading, onClose]);
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{t('explorer:browser.uploadDialog.title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{/* Drop zone */}
|
||||||
|
<Box
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
sx={{
|
||||||
|
border: 2,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: dragOver ? 'primary.main' : 'grey.400',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: dragOver ? 'action.hover' : 'transparent',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<UploadIcon sx={{ fontSize: 48, color: 'grey.500', mb: 1 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{t('explorer:browser.uploadDialog.dropzone')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{t('explorer:browser.uploadDialog.or')}
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" sx={{ mt: 1 }}>
|
||||||
|
{t('explorer:browser.uploadDialog.browse')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
{t('explorer:browser.uploadDialog.selectedFiles', { count: files.length })}
|
||||||
|
</Typography>
|
||||||
|
<List dense sx={{ maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={index}
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveFile(index)}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<FileIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={file.name}
|
||||||
|
secondary={formatSize(file.size)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload progress */}
|
||||||
|
{uploading && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<LinearProgress />
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 1 }}>
|
||||||
|
{t('explorer:browser.uploadDialog.uploading')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose} disabled={uploading}>
|
||||||
|
{t('common:actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={files.length === 0 || uploading}
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
>
|
||||||
|
{t('explorer:browser.upload')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadDialog;
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Clear as ClearIcon,
|
||||||
|
Send as SendIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useTerminalStore } from '../../../store/terminalStore';
|
||||||
|
|
||||||
|
const Terminal: React.FC = () => {
|
||||||
|
const { t } = useTranslation(['explorer', 'common']);
|
||||||
|
const {
|
||||||
|
commandHistory,
|
||||||
|
outputHistory,
|
||||||
|
isExecuting,
|
||||||
|
executeCommand,
|
||||||
|
clearOutput,
|
||||||
|
} = useTerminalStore();
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when output changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (outputRef.current) {
|
||||||
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [outputHistory]);
|
||||||
|
|
||||||
|
// Focus input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const command = inputValue.trim();
|
||||||
|
if (!command || isExecuting) return;
|
||||||
|
|
||||||
|
setInputValue('');
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
await executeCommand(command);
|
||||||
|
}, [inputValue, isExecuting, executeCommand]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (commandHistory.length === 0) return;
|
||||||
|
|
||||||
|
const newIndex = historyIndex < commandHistory.length - 1
|
||||||
|
? historyIndex + 1
|
||||||
|
: historyIndex;
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIndex <= 0) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setInputValue('');
|
||||||
|
} else {
|
||||||
|
const newIndex = historyIndex - 1;
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [commandHistory, historyIndex]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
clearOutput();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [clearOutput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: 'calc(100vh - 250px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h6">{t('explorer:terminal.title')}</Typography>
|
||||||
|
<Tooltip title={t('explorer:terminal.clear')}>
|
||||||
|
<IconButton onClick={handleClear} size="small">
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
bgcolor: 'grey.900',
|
||||||
|
color: 'grey.100',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Output area */}
|
||||||
|
<Box
|
||||||
|
ref={outputRef}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
p: 2,
|
||||||
|
'& pre': {
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outputHistory.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ color: 'grey.500', fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{t('explorer:terminal.welcomeMessage')}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
outputHistory.map((entry) => (
|
||||||
|
<Box key={entry.id} sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ color: 'primary.light', mb: 0.5 }}>
|
||||||
|
<span style={{ color: '#4caf50' }}>$</span> {entry.command}
|
||||||
|
</Box>
|
||||||
|
{entry.output && (
|
||||||
|
<pre style={{ color: '#e0e0e0' }}>{entry.output}</pre>
|
||||||
|
)}
|
||||||
|
{entry.error && (
|
||||||
|
<pre style={{ color: '#f44336' }}>{entry.error}</pre>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isExecuting && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'grey.500' }}>
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
<span>{t('explorer:terminal.executing')}</span>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'grey.800',
|
||||||
|
p: 1,
|
||||||
|
bgcolor: 'grey.800',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{ color: '#4caf50', mr: 1, fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
$
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
inputRef={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isExecuting}
|
||||||
|
placeholder={t('explorer:terminal.placeholder')}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
InputProps={{
|
||||||
|
disableUnderline: true,
|
||||||
|
sx: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'grey.100',
|
||||||
|
'& input::placeholder': {
|
||||||
|
color: 'grey.600',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title={t('explorer:terminal.execute')}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isExecuting || !inputValue.trim()}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'grey.400' }}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{t('explorer:terminal.helpText')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Terminal;
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ChevronLeft as ChevronLeftIcon,
|
ChevronLeft as ChevronLeftIcon,
|
||||||
Dashboard as DashboardIcon,
|
Dashboard as DashboardIcon,
|
||||||
Storage as StorageIcon,
|
Storage as StorageIcon,
|
||||||
|
FolderOpen as FolderOpenIcon,
|
||||||
People as PeopleIcon,
|
People as PeopleIcon,
|
||||||
Policy as PolicyIcon,
|
Policy as PolicyIcon,
|
||||||
Assessment as AssessmentIcon,
|
Assessment as AssessmentIcon,
|
||||||
@@ -55,6 +56,7 @@ const Layout: React.FC = () => {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' },
|
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' },
|
||||||
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets', translationKey: 'navigation.buckets' },
|
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets', translationKey: 'navigation.buckets' },
|
||||||
|
{ text: 'Explorer', icon: <FolderOpenIcon />, path: '/explorer', translationKey: 'navigation.explorer' },
|
||||||
{ text: 'Users', icon: <PeopleIcon />, path: '/users', translationKey: 'navigation.users' },
|
{ text: 'Users', icon: <PeopleIcon />, path: '/users', translationKey: 'navigation.users' },
|
||||||
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' },
|
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' },
|
||||||
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' },
|
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' },
|
||||||
@@ -224,7 +226,7 @@ const Layout: React.FC = () => {
|
|||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<ListItem key={item.text} disablePadding>
|
<ListItem key={item.text} disablePadding>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
selected={location.pathname === item.path}
|
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
>
|
>
|
||||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import enDashboard from './locales/en/dashboard.json';
|
|||||||
import enQuickWizard from './locales/en/quickWizard.json';
|
import enQuickWizard from './locales/en/quickWizard.json';
|
||||||
import enReports from './locales/en/reports.json';
|
import enReports from './locales/en/reports.json';
|
||||||
import enErrors from './locales/en/errors.json';
|
import enErrors from './locales/en/errors.json';
|
||||||
|
import enExplorer from './locales/en/explorer.json';
|
||||||
|
|
||||||
import deCommon from './locales/de/common.json';
|
import deCommon from './locales/de/common.json';
|
||||||
import deDashboard from './locales/de/dashboard.json';
|
import deDashboard from './locales/de/dashboard.json';
|
||||||
import deQuickWizard from './locales/de/quickWizard.json';
|
import deQuickWizard from './locales/de/quickWizard.json';
|
||||||
import deReports from './locales/de/reports.json';
|
import deReports from './locales/de/reports.json';
|
||||||
import deErrors from './locales/de/errors.json';
|
import deErrors from './locales/de/errors.json';
|
||||||
|
import deExplorer from './locales/de/explorer.json';
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
en: {
|
en: {
|
||||||
@@ -22,6 +24,7 @@ const resources = {
|
|||||||
quickWizard: enQuickWizard,
|
quickWizard: enQuickWizard,
|
||||||
reports: enReports,
|
reports: enReports,
|
||||||
errors: enErrors,
|
errors: enErrors,
|
||||||
|
explorer: enExplorer,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@@ -29,6 +32,7 @@ const resources = {
|
|||||||
quickWizard: deQuickWizard,
|
quickWizard: deQuickWizard,
|
||||||
reports: deReports,
|
reports: deReports,
|
||||||
errors: deErrors,
|
errors: deErrors,
|
||||||
|
explorer: deExplorer,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ i18n
|
|||||||
resources,
|
resources,
|
||||||
lng: 'de', // Default to German
|
lng: 'de', // Default to German
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors'],
|
ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors', 'explorer'],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false, // React already escapes values
|
escapeValue: false, // React already escapes values
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"navigation": {
|
"navigation": {
|
||||||
"dashboard": "Übersicht",
|
"dashboard": "Übersicht",
|
||||||
"buckets": "Buckets",
|
"buckets": "Buckets",
|
||||||
|
"explorer": "Explorer",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"policies": "Richtlinien",
|
"policies": "Richtlinien",
|
||||||
"reports": "Berichte",
|
"reports": "Berichte",
|
||||||
|
|||||||
@@ -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": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"buckets": "Buckets",
|
"buckets": "Buckets",
|
||||||
|
"explorer": "Explorer",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"policies": "Policies",
|
"policies": "Policies",
|
||||||
"reports": "Reports",
|
"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