diff --git a/backend/package.json b/backend/package.json
index 42c42c9..6437c5a 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -33,7 +33,8 @@
"node-cron": "^3.0.3",
"nodemailer": "^6.9.8",
"winston": "^3.11.0",
- "winston-daily-rotate-file": "^4.7.1"
+ "winston-daily-rotate-file": "^4.7.1",
+ "multer": "^1.4.5-lts.1"
},
"devDependencies": {
"eslint": "^8.56.0",
diff --git a/backend/src/api/aliases/index.js b/backend/src/api/aliases/index.js
new file mode 100644
index 0000000..6198f6d
--- /dev/null
+++ b/backend/src/api/aliases/index.js
@@ -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;
diff --git a/backend/src/api/browser/index.js b/backend/src/api/browser/index.js
new file mode 100644
index 0000000..6695aa6
--- /dev/null
+++ b/backend/src/api/browser/index.js
@@ -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;
diff --git a/backend/src/api/terminal/index.js b/backend/src/api/terminal/index.js
new file mode 100644
index 0000000..f5df328
--- /dev/null
+++ b/backend/src/api/terminal/index.js
@@ -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;
diff --git a/backend/src/app.js b/backend/src/app.js
index 0a50f13..677c8d6 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -18,6 +18,9 @@ const bucketRoutes = require('./api/buckets');
const userRoutes = require('./api/users');
const policyRoutes = require('./api/policies');
const reportRoutes = require('./api/reports');
+const terminalRoutes = require('./api/terminal');
+const aliasRoutes = require('./api/aliases');
+const browserRoutes = require('./api/browser');
// Create Express app
const app = express();
@@ -111,6 +114,22 @@ const authLimiter = rateLimit({
app.use('/api/auth/login', authLimiter);
+// Rate limiting for terminal commands (30 per minute)
+const terminalLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 30,
+ message: 'Too many commands, please wait.',
+});
+app.use('/api/terminal', terminalLimiter);
+
+// Rate limiting for browser operations (60 per minute)
+const browserLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 60,
+ message: 'Too many file operations, please slow down.',
+});
+app.use('/api/browser', browserLimiter);
+
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
@@ -126,6 +145,9 @@ app.use('/api/buckets', bucketRoutes);
app.use('/api/users', userRoutes);
app.use('/api/policies', policyRoutes);
app.use('/api/reports', reportRoutes);
+app.use('/api/terminal', terminalRoutes);
+app.use('/api/aliases', aliasRoutes);
+app.use('/api/browser', browserRoutes);
// 404 handler
app.use((req, res) => {
diff --git a/backend/src/middleware/upload.middleware.js b/backend/src/middleware/upload.middleware.js
new file mode 100644
index 0000000..f95fcbc
--- /dev/null
+++ b/backend/src/middleware/upload.middleware.js
@@ -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,
+};
diff --git a/backend/src/services/minio.service.js b/backend/src/services/minio.service.js
index 932a705..16149d1 100644
--- a/backend/src/services/minio.service.js
+++ b/backend/src/services/minio.service.js
@@ -483,7 +483,328 @@ class MinIOService {
return name && name.length >= 1 && name.length <= 32 && regex.test(name);
}
+// ============================================
+ // Terminal Methods (Raw Command Execution)
+ // ============================================
+
+ async executeRawCommand(command, options = {}) {
+ const { timeout = 30000 } = options;
+ const startTime = Date.now();
+
+ // Security: Only allow mc commands
+ if (!command.trim().startsWith('mc ')) {
+ return {
+ success: false,
+ output: '',
+ error: 'Only mc commands are allowed',
+ exitCode: 1,
+ executionTime: Date.now() - startTime,
+ };
+ }
+
+ // Security: Block shell operators
+ const blockedPatterns = [/;/, /\|/, /`/, /\$\(/, />>?/, /<, /&&/, /\|\|/];
+ for (const pattern of blockedPatterns) {
+ if (pattern.test(command)) {
+ return {
+ success: false,
+ output: '',
+ error: 'Command contains forbidden shell operators',
+ exitCode: 1,
+ executionTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ try {
+ logger.debug(`Executing raw command: ${command}`);
+
+ const { stdout, stderr } = await execAsync(command, {
+ timeout,
+ maxBuffer: 10 * 1024 * 1024,
+ env: { ...process.env, MC_NO_COLOR: '1' },
+ });
+
+ return {
+ success: true,
+ output: stdout || '',
+ error: stderr || '',
+ exitCode: 0,
+ executionTime: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ output: error.stdout || '',
+ error: error.stderr || error.message,
+ exitCode: error.code || 1,
+ executionTime: Date.now() - startTime,
+ };
+ }
+ }
+
+ // ============================================
+ // Extended Alias Management Methods
+ // ============================================
+
+ async getAlias(aliasName) {
+ const aliases = await this.listAliases();
+ const alias = aliases.find(a => a.alias === aliasName);
+ if (!alias) {
+ throw new AppError('Alias not found', 404);
+ }
+ return alias;
+ }
+
+ async updateAlias(aliasName, endpoint, accessKey, secretKey) {
+ if (!this.isValidAliasName(aliasName)) {
+ throw new AppError('Invalid alias name', 400);
+ }
+
+ // mc alias set overwrites existing alias
+ await this.executeCommand(
+ `mc alias set ${aliasName} ${endpoint} ${accessKey} ${secretKey}`
+ );
+
+ const testResult = await this.testConnection(aliasName);
+ return {
+ message: 'Alias updated successfully',
+ aliasName,
+ connectionStatus: testResult.status,
+ };
+ }
+
+ async removeAlias(aliasName) {
+ if (!this.isValidAliasName(aliasName)) {
+ throw new AppError('Invalid alias name', 400);
+ }
+
+ await this.executeCommand(`mc alias remove ${aliasName}`);
+ return { message: 'Alias removed successfully', aliasName };
+ }
+
+ // ============================================
+ // File Browser Methods
+ // ============================================
+
+ validatePath(pathStr) {
+ if (!pathStr) return true;
+
+ const decoded = decodeURIComponent(pathStr);
+
+ // Path traversal prevention
+ if (decoded.includes('..')) {
+ throw new AppError('Path traversal not allowed', 400);
+ }
+
+ // Null byte injection prevention
+ if (decoded.includes('\0')) {
+ throw new AppError('Invalid path', 400);
+ }
+
+ // Command injection prevention in paths
+ const dangerousPatterns = [';', '|', '`', '$', '>', '<', '&'];
+ for (const pattern of dangerousPatterns) {
+ if (decoded.includes(pattern)) {
+ throw new AppError('Invalid characters in path', 400);
+ }
+ }
+
+ return true;
+ }
+
+ async listObjects(alias, bucket, options = {}) {
+ const { prefix = '', recursive = false } = options;
+
+ // Validate path security
+ this.validatePath(prefix);
+
+ const targetPath = prefix
+ ? `${alias}/${bucket}/${prefix}`
+ : `${alias}/${bucket}`;
+ const recursiveFlag = recursive ? '--recursive' : '';
+
+ try {
+ const output = await this.executeCommand(
+ `mc ls "${targetPath}" ${recursiveFlag} --json`,
+ { parseJson: true, timeout: 60000 }
+ );
+
+ let objects = Array.isArray(output) ? output : output ? [output] : [];
+
+ // Transform results
+ objects = objects.map(obj => ({
+ key: obj.key || '',
+ name: (obj.key || '').split('/').filter(Boolean).pop() || obj.key || '',
+ size: obj.size || 0,
+ sizeFormatted: this.formatBytes(obj.size || 0),
+ lastModified: obj.lastModified || null,
+ type: (obj.key || '').endsWith('/') ? 'folder' : 'file',
+ etag: obj.etag || null,
+ }));
+
+ return {
+ objects,
+ prefix,
+ count: objects.length,
+ };
+ } catch (error) {
+ // Return empty list if path doesn't exist or is empty
+ if (error.message && error.message.includes('not found')) {
+ return { objects: [], prefix, count: 0 };
+ }
+ throw error;
+ }
+ }
+
+ async getObjectStat(alias, bucket, objectPath) {
+ this.validatePath(objectPath);
+
+ const output = await this.executeCommand(
+ `mc stat "${alias}/${bucket}/${objectPath}" --json`,
+ { parseJson: true }
+ );
+
+ return {
+ key: objectPath,
+ name: objectPath.split('/').filter(Boolean).pop() || objectPath,
+ size: output.size || 0,
+ sizeFormatted: this.formatBytes(output.size || 0),
+ lastModified: output.lastModified || null,
+ contentType: output.metadata?.['content-type'] || 'application/octet-stream',
+ etag: output.etag || null,
+ metadata: output.metadata || {},
+ };
+ }
+
+ async downloadObject(alias, bucket, objectPath) {
+ this.validatePath(objectPath);
+
+ // Generate unique temp file path
+ const fileName = objectPath.split('/').filter(Boolean).pop() || 'download';
+ const sanitizedName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
+ const tempFilePath = path.join(
+ this.tempDir,
+ `${Date.now()}-${crypto.randomBytes(8).toString('hex')}-${sanitizedName}`
+ );
+
+ await this.executeCommand(
+ `mc cp "${alias}/${bucket}/${objectPath}" "${tempFilePath}"`,
+ { timeout: 300000 } // 5 min for large files
+ );
+
+ return {
+ filePath: tempFilePath,
+ fileName,
+ cleanup: async () => {
+ await fs.unlink(tempFilePath).catch(() => {});
+ },
+ };
+ }
+
+ async uploadObject(alias, bucket, objectPath, localFilePath) {
+ this.validatePath(objectPath);
+
+ await this.executeCommand(
+ `mc cp "${localFilePath}" "${alias}/${bucket}/${objectPath}"`,
+ { timeout: 300000 }
+ );
+
+ return { key: objectPath, message: 'File uploaded successfully' };
+ }
+
+ async deleteObject(alias, bucket, objectPath, recursive = false) {
+ this.validatePath(objectPath);
+
+ const recursiveFlag = recursive ? '--recursive --force' : '';
+
+ await this.executeCommand(
+ `mc rm "${alias}/${bucket}/${objectPath}" ${recursiveFlag}`,
+ { timeout: 60000 }
+ );
+
+ return { message: 'Object deleted successfully', key: objectPath };
+ }
+
+ async renameObject(alias, bucket, sourcePath, destPath) {
+ this.validatePath(sourcePath);
+ this.validatePath(destPath);
+
+ // mc mv for rename
+ await this.executeCommand(
+ `mc mv "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
+ { timeout: 60000 }
+ );
+
+ return {
+ message: 'Object renamed successfully',
+ source: sourcePath,
+ destination: destPath,
+ };
+ }
+
+ async copyObject(alias, bucket, sourcePath, destPath) {
+ this.validatePath(sourcePath);
+ this.validatePath(destPath);
+
+ await this.executeCommand(
+ `mc cp "${alias}/${bucket}/${sourcePath}" "${alias}/${bucket}/${destPath}"`,
+ { timeout: 60000 }
+ );
+
+ return {
+ message: 'Object copied successfully',
+ source: sourcePath,
+ destination: destPath,
+ };
+ }
+
+ async createFolder(alias, bucket, folderPath) {
+ this.validatePath(folderPath);
+
+ // Ensure path ends with /
+ const normalizedPath = folderPath.endsWith('/') ? folderPath : `${folderPath}/`;
+
+ // Create empty object to represent folder (.keep file)
+ const emptyFile = path.join(this.tempDir, `.empty-${Date.now()}`);
+ await fs.writeFile(emptyFile, '');
+
+ try {
+ await this.executeCommand(
+ `mc cp "${emptyFile}" "${alias}/${bucket}/${normalizedPath}.keep"`,
+ { timeout: 30000 }
+ );
+ } finally {
+ await fs.unlink(emptyFile).catch(() => {});
+ }
+
+ return { message: 'Folder created successfully', path: normalizedPath };
+ }
+
+ async getPathSize(alias, bucket, objectPath = '') {
+ this.validatePath(objectPath);
+
+ const targetPath = objectPath
+ ? `${alias}/${bucket}/${objectPath}`
+ : `${alias}/${bucket}`;
+
+ const output = await this.executeCommand(
+ `mc du "${targetPath}" --json`,
+ { parseJson: true, timeout: 120000 }
+ );
+
+ return {
+ path: objectPath || '/',
+ size: output.size || 0,
+ sizeFormatted: this.formatBytes(output.size || 0),
+ objects: output.objects || 0,
+ };
+ }
+
+ // ============================================
// Utility Methods
+ // ============================================
+
formatBytes(bytes) {
if (bytes === 0) return '0 B';
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8d0e967..ebb7fb1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets';
import Users from './components/Users/Users';
import Policies from './components/Policies/Policies';
import Reports from './components/Reports/Reports';
+import Explorer from './components/Explorer/Explorer';
+import Aliases from './components/Explorer/Aliases/Aliases';
+import Terminal from './components/Explorer/Terminal/Terminal';
+import FileBrowser from './components/Explorer/FileBrowser/FileBrowser';
import './i18n';
function App() {
@@ -30,6 +34,13 @@ function App() {
}>
} />
} />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
} />
} />
} />
diff --git a/frontend/src/components/Explorer/Aliases/AliasDialog.tsx b/frontend/src/components/Explorer/Aliases/AliasDialog.tsx
new file mode 100644
index 0000000..4be3c3c
--- /dev/null
+++ b/frontend/src/components/Explorer/Aliases/AliasDialog.tsx
@@ -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 = ({ 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 (
+
+ );
+};
+
+export default AliasDialog;
diff --git a/frontend/src/components/Explorer/Aliases/Aliases.tsx b/frontend/src/components/Explorer/Aliases/Aliases.tsx
new file mode 100644
index 0000000..c12ee92
--- /dev/null
+++ b/frontend/src/components/Explorer/Aliases/Aliases.tsx
@@ -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(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [aliasToDelete, setAliasToDelete] = useState(null);
+ const [testingAlias, setTestingAlias] = useState(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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {t('explorer:aliases.title')}
+
+ }
+ onClick={() => loadAliases()}
+ sx={{ mr: 1 }}
+ >
+ {t('common:actions.refresh')}
+
+ }
+ onClick={handleAddAlias}
+ >
+ {t('explorer:aliases.addAlias')}
+
+
+
+
+
+
+
+
+ {t('explorer:aliases.columns.name')}
+ {t('explorer:aliases.columns.endpoint')}
+ {t('explorer:aliases.columns.status')}
+ {t('explorer:aliases.columns.actions')}
+
+
+
+ {aliases.length === 0 ? (
+
+
+
+
+ {t('explorer:aliases.empty.title')}
+
+
+ {t('explorer:aliases.empty.message')}
+
+
+
+
+ ) : (
+ aliases.map((alias) => (
+ handleAliasClick(alias)}
+ >
+
+
+ {alias.alias}
+
+
+
+
+ {alias.URL}
+
+
+
+
+
+ e.stopPropagation()}>
+
+ handleTestConnection(alias.alias)}
+ disabled={testingAlias === alias.alias}
+ >
+ {testingAlias === alias.alias ? (
+
+ ) : (
+
+ )}
+
+
+
+ handleEditAlias(alias)}
+ >
+
+
+
+
+ handleDeleteClick(alias.alias)}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ setDialogOpen(false)}
+ onSuccess={handleDialogSuccess}
+ />
+
+ setDeleteDialogOpen(false)}
+ />
+
+ setSnackbar({ ...snackbar, open: false })}
+ >
+ setSnackbar({ ...snackbar, open: false })}>
+ {snackbar.message}
+
+
+
+ );
+};
+
+export default Aliases;
diff --git a/frontend/src/components/Explorer/Explorer.tsx b/frontend/src/components/Explorer/Explorer.tsx
new file mode 100644
index 0000000..a137711
--- /dev/null
+++ b/frontend/src/components/Explorer/Explorer.tsx
@@ -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 (
+
+
+ {t('explorer:title')}
+
+
+ {currentTab !== -1 && (
+
+
+ }
+ iconPosition="start"
+ label={t('explorer:tabs.aliases')}
+ />
+ }
+ iconPosition="start"
+ label={t('explorer:tabs.terminal')}
+ />
+
+
+ )}
+
+
+
+ );
+};
+
+export default Explorer;
diff --git a/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx b/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx
new file mode 100644
index 0000000..5f47185
--- /dev/null
+++ b/frontend/src/components/Explorer/FileBrowser/BreadcrumbNav.tsx
@@ -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 = ({
+ 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 (
+
+ }
+ aria-label="breadcrumb"
+ >
+ {/* Alias link - goes back to aliases list */}
+ window.location.href = '/explorer/aliases'}
+ underline="hover"
+ color="inherit"
+ sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
+ >
+
+ {alias}
+
+
+ {/* Bucket link */}
+ {pathParts.length > 0 ? (
+ handleClick(-1)}
+ underline="hover"
+ color="inherit"
+ sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
+ >
+
+ {bucket}
+
+ ) : (
+
+
+ {bucket}
+
+ )}
+
+ {/* Path parts */}
+ {pathParts.map((part, index) => {
+ const isLast = index === pathParts.length - 1;
+ return isLast ? (
+
+ {part}
+
+ ) : (
+ handleClick(index)}
+ underline="hover"
+ color="inherit"
+ sx={{ cursor: 'pointer' }}
+ >
+ {part}
+
+ );
+ })}
+
+
+ );
+};
+
+export default BreadcrumbNav;
diff --git a/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx b/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx
new file mode 100644
index 0000000..d7f8101
--- /dev/null
+++ b/frontend/src/components/Explorer/FileBrowser/CreateFolderDialog.tsx
@@ -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 = ({
+ 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 (
+
+ );
+};
+
+export default CreateFolderDialog;
diff --git a/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx b/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx
new file mode 100644
index 0000000..0599759
--- /dev/null
+++ b/frontend/src/components/Explorer/FileBrowser/FileBrowser.tsx
@@ -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([]);
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
+ const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [itemToRename, setItemToRename] = useState(null);
+ const [itemToDelete, setItemToDelete] = useState(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) => {
+ 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 (
+
+ {t('explorer:browser.errors.missingParams')}
+
+ );
+ }
+
+ return (
+
+ {/* Breadcrumb navigation */}
+
+
+ {/* Toolbar */}
+ 0 ? 'action.selected' : 'transparent',
+ mb: 2,
+ }}
+ >
+ {selected.length > 0 ? (
+ <>
+
+ {t('explorer:browser.selected', { count: selected.length })}
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ {bucket}
+
+
+ loadFiles(alias, bucket, currentPath)}>
+
+
+
+
+ setCreateFolderDialogOpen(true)}>
+
+
+
+ }
+ onClick={() => setUploadDialogOpen(true)}
+ sx={{ ml: 1 }}
+ >
+ {t('explorer:browser.upload')}
+
+ >
+ )}
+
+
+ {/* File table */}
+ {filesLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+ 0 && selected.length < files.length}
+ checked={files.length > 0 && selected.length === files.length}
+ onChange={handleSelectAll}
+ />
+
+ {t('explorer:browser.columns.name')}
+ {t('explorer:browser.columns.size')}
+ {t('explorer:browser.columns.type')}
+ {t('explorer:browser.columns.lastModified')}
+ {t('explorer:browser.columns.actions')}
+
+
+
+ {files.length === 0 ? (
+
+
+
+
+ {t('explorer:browser.empty.title')}
+
+
+ {t('explorer:browser.empty.message')}
+
+
+
+
+ ) : (
+ files.map((item) => (
+ handleItemClick(item)}
+ >
+ e.stopPropagation()}>
+ handleSelect(item.key)}
+ />
+
+
+
+ {item.type === 'folder' ? (
+
+ ) : (
+
+ )}
+ {item.name}
+
+
+ {formatSize(item.size)}
+
+
+ {item.type === 'folder' ? t('explorer:browser.types.folder') : (item.contentType || t('explorer:browser.types.file'))}
+
+
+ {formatDate(item.lastModified)}
+ e.stopPropagation()}>
+ {item.type !== 'folder' && (
+
+ handleDownload(item)}>
+
+
+
+ )}
+
+ handleRenameClick(item)}>
+
+
+
+
+ handleDeleteClick(item.key)}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+ )}
+
+ {/* Dialogs */}
+ setUploadDialogOpen(false)}
+ onUpload={handleUpload}
+ />
+
+ setCreateFolderDialogOpen(false)}
+ onConfirm={handleCreateFolder}
+ />
+
+ {
+ setRenameDialogOpen(false);
+ setItemToRename(null);
+ }}
+ onConfirm={handleRenameConfirm}
+ />
+
+ {
+ setDeleteDialogOpen(false);
+ setItemToDelete(null);
+ }}
+ />
+
+ setSnackbar({ ...snackbar, open: false })}
+ >
+ setSnackbar({ ...snackbar, open: false })}>
+ {snackbar.message}
+
+
+
+ );
+};
+
+export default FileBrowser;
diff --git a/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx b/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx
new file mode 100644
index 0000000..0971e1b
--- /dev/null
+++ b/frontend/src/components/Explorer/FileBrowser/RenameDialog.tsx
@@ -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 = ({
+ 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 (
+
+ );
+};
+
+export default RenameDialog;
diff --git a/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx b/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx
new file mode 100644
index 0000000..4396b8f
--- /dev/null
+++ b/frontend/src/components/Explorer/FileBrowser/UploadDialog.tsx
@@ -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;
+}
+
+const UploadDialog: React.FC = ({ open, onClose, onUpload }) => {
+ const { t } = useTranslation(['explorer', 'common']);
+ const [files, setFiles] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+ const inputRef = useRef(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) => {
+ 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 (
+
+ );
+};
+
+export default UploadDialog;
diff --git a/frontend/src/components/Explorer/Terminal/Terminal.tsx b/frontend/src/components/Explorer/Terminal/Terminal.tsx
new file mode 100644
index 0000000..e74f951
--- /dev/null
+++ b/frontend/src/components/Explorer/Terminal/Terminal.tsx
@@ -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(null);
+ const inputRef = useRef(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 (
+
+
+ {t('explorer:terminal.title')}
+
+
+
+
+
+
+
+
+ {/* Output area */}
+
+ {outputHistory.length === 0 ? (
+
+ {t('explorer:terminal.welcomeMessage')}
+
+ ) : (
+ outputHistory.map((entry) => (
+
+
+ $ {entry.command}
+
+ {entry.output && (
+ {entry.output}
+ )}
+ {entry.error && (
+ {entry.error}
+ )}
+
+ ))
+ )}
+ {isExecuting && (
+
+
+ {t('explorer:terminal.executing')}
+
+ )}
+
+
+ {/* Input area */}
+
+
+ $
+
+ 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,
+ },
+ },
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {t('explorer:terminal.helpText')}
+
+
+ );
+};
+
+export default Terminal;
diff --git a/frontend/src/components/Layout/Layout.tsx b/frontend/src/components/Layout/Layout.tsx
index bf5e25e..11f6a2b 100644
--- a/frontend/src/components/Layout/Layout.tsx
+++ b/frontend/src/components/Layout/Layout.tsx
@@ -25,6 +25,7 @@ import {
ChevronLeft as ChevronLeftIcon,
Dashboard as DashboardIcon,
Storage as StorageIcon,
+ FolderOpen as FolderOpenIcon,
People as PeopleIcon,
Policy as PolicyIcon,
Assessment as AssessmentIcon,
@@ -55,6 +56,7 @@ const Layout: React.FC = () => {
const navItems: NavItem[] = [
{ text: 'Dashboard', icon: , path: '/', translationKey: 'navigation.dashboard' },
{ text: 'Buckets', icon: , path: '/buckets', translationKey: 'navigation.buckets' },
+ { text: 'Explorer', icon: , path: '/explorer', translationKey: 'navigation.explorer' },
{ text: 'Users', icon: , path: '/users', translationKey: 'navigation.users' },
{ text: 'Policies', icon: , path: '/policies', translationKey: 'navigation.policies' },
{ text: 'Reports', icon: , path: '/reports', translationKey: 'navigation.reports' },
@@ -224,7 +226,7 @@ const Layout: React.FC = () => {
{navItems.map((item) => (
navigate(item.path)}
>
{item.icon}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 41f2307..cebfe00 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -8,12 +8,14 @@ import enDashboard from './locales/en/dashboard.json';
import enQuickWizard from './locales/en/quickWizard.json';
import enReports from './locales/en/reports.json';
import enErrors from './locales/en/errors.json';
+import enExplorer from './locales/en/explorer.json';
import deCommon from './locales/de/common.json';
import deDashboard from './locales/de/dashboard.json';
import deQuickWizard from './locales/de/quickWizard.json';
import deReports from './locales/de/reports.json';
import deErrors from './locales/de/errors.json';
+import deExplorer from './locales/de/explorer.json';
const resources = {
en: {
@@ -22,6 +24,7 @@ const resources = {
quickWizard: enQuickWizard,
reports: enReports,
errors: enErrors,
+ explorer: enExplorer,
},
de: {
common: deCommon,
@@ -29,6 +32,7 @@ const resources = {
quickWizard: deQuickWizard,
reports: deReports,
errors: deErrors,
+ explorer: deExplorer,
},
};
@@ -39,7 +43,7 @@ i18n
resources,
lng: 'de', // Default to German
fallbackLng: 'en',
- ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors'],
+ ns: ['common', 'dashboard', 'quickWizard', 'reports', 'errors', 'explorer'],
defaultNS: 'common',
interpolation: {
escapeValue: false, // React already escapes values
diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json
index 3bccb6d..2922b24 100644
--- a/frontend/src/locales/de/common.json
+++ b/frontend/src/locales/de/common.json
@@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Übersicht",
"buckets": "Buckets",
+ "explorer": "Explorer",
"users": "Benutzer",
"policies": "Richtlinien",
"reports": "Berichte",
diff --git a/frontend/src/locales/de/explorer.json b/frontend/src/locales/de/explorer.json
new file mode 100644
index 0000000..a5ee235
--- /dev/null
+++ b/frontend/src/locales/de/explorer.json
@@ -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"
+ }
+ }
+}
diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json
index e08a510..7e423a3 100644
--- a/frontend/src/locales/en/common.json
+++ b/frontend/src/locales/en/common.json
@@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Dashboard",
"buckets": "Buckets",
+ "explorer": "Explorer",
"users": "Users",
"policies": "Policies",
"reports": "Reports",
diff --git a/frontend/src/locales/en/explorer.json b/frontend/src/locales/en/explorer.json
new file mode 100644
index 0000000..f75032f
--- /dev/null
+++ b/frontend/src/locales/en/explorer.json
@@ -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"
+ }
+ }
+}
diff --git a/frontend/src/services/aliasService.ts b/frontend/src/services/aliasService.ts
new file mode 100644
index 0000000..c0d2211
--- /dev/null
+++ b/frontend/src/services/aliasService.ts
@@ -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 {
+ const response = await api.get<{ aliases: Alias[]; count: number }>('/aliases');
+ return response.data.aliases;
+ }
+
+ async getAlias(name: string): Promise {
+ const response = await api.get(`/aliases/${name}`);
+ return response.data;
+ }
+
+ async addAlias(alias: AliasInput): Promise {
+ await api.post('/aliases', alias);
+ }
+
+ async updateAlias(name: string, alias: Omit): Promise {
+ await api.put(`/aliases/${name}`, alias);
+ }
+
+ async removeAlias(name: string): Promise {
+ await api.delete(`/aliases/${name}`);
+ }
+
+ async testConnection(name: string): Promise {
+ const response = await api.post(`/aliases/${name}/test`);
+ return response.data;
+ }
+}
+
+export default new AliasService();
diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts
new file mode 100644
index 0000000..a408792
--- /dev/null
+++ b/frontend/src/services/fileService.ts
@@ -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;
+}
+
+export interface PathSize {
+ path: string;
+ size: number;
+ sizeFormatted: string;
+ objects: number;
+}
+
+class FileService {
+ async listFiles(alias: string, bucket: string, prefix?: string, recursive?: boolean): Promise {
+ const response = await api.get(`/browser/${alias}/${bucket}`, {
+ params: { prefix, recursive },
+ });
+ return response.data.objects;
+ }
+
+ async getFileStat(alias: string, bucket: string, path: string): Promise {
+ const response = await api.get(`/browser/${alias}/${bucket}/stat/${encodeURIComponent(path)}`);
+ return response.data;
+ }
+
+ async getPathSize(alias: string, bucket: string, path?: string): Promise {
+ const url = path
+ ? `/browser/${alias}/${bucket}/size/${encodeURIComponent(path)}`
+ : `/browser/${alias}/${bucket}/size`;
+ const response = await api.get(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 {
+ await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
+ params: { recursive },
+ });
+ }
+
+ async renameFile(alias: string, bucket: string, source: string, destination: string): Promise {
+ await api.post(`/browser/${alias}/${bucket}/rename`, {
+ source,
+ destination,
+ });
+ }
+
+ async copyFile(alias: string, bucket: string, source: string, destination: string): Promise {
+ await api.post(`/browser/${alias}/${bucket}/copy`, {
+ source,
+ destination,
+ });
+ }
+
+ async createFolder(alias: string, bucket: string, folderName: string, prefix?: string): Promise {
+ await api.post(
+ `/browser/${alias}/${bucket}/mkdir`,
+ { folderName },
+ { params: { prefix } }
+ );
+ }
+
+ async downloadFile(alias: string, bucket: string, path: string): Promise {
+ 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();
diff --git a/frontend/src/services/terminalService.ts b/frontend/src/services/terminalService.ts
new file mode 100644
index 0000000..48e4293
--- /dev/null
+++ b/frontend/src/services/terminalService.ts
@@ -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 {
+ const response = await api.post('/terminal/execute', {
+ command,
+ timeout,
+ });
+ return response.data;
+ }
+}
+
+export default new TerminalService();
diff --git a/frontend/src/store/explorerStore.ts b/frontend/src/store/explorerStore.ts
new file mode 100644
index 0000000..4fb841d
--- /dev/null
+++ b/frontend/src/store/explorerStore.ts
@@ -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;
+ addAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise;
+ updateAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise;
+ removeAlias: (aliasName: string) => Promise;
+ 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;
+ uploadFiles: (alias: string, bucket: string, files: File[], prefix?: string) => Promise;
+ deleteFile: (alias: string, bucket: string, path: string, recursive?: boolean) => Promise;
+ renameFile: (alias: string, bucket: string, source: string, destination: string) => Promise;
+ createFolder: (alias: string, bucket: string, folderName: string, prefix?: string) => Promise;
+ downloadFile: (alias: string, bucket: string, path: string) => Promise;
+
+ // Selection
+ toggleFileSelection: (key: string) => void;
+ clearSelection: () => void;
+ selectAll: () => void;
+}
+
+export const useExplorerStore = create((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),
+ }));
+ },
+}));
diff --git a/frontend/src/store/terminalStore.ts b/frontend/src/store/terminalStore.ts
new file mode 100644
index 0000000..fd38c28
--- /dev/null
+++ b/frontend/src/store/terminalStore.ts
@@ -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;
+ clearOutput: () => void;
+ navigateHistory: (direction: 'up' | 'down') => void;
+ setCurrentCommand: (command: string) => void;
+}
+
+export const useTerminalStore = create((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 });
+ },
+}));