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

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

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

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

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

Frontend:
- New Explorer component with Aliases/Terminal tabs
- Zustand stores for terminal and explorer state
- i18n translations (English and German)
This commit is contained in:
Paul Nothaft
2026-01-04 22:19:03 +01:00
parent feae9882bc
commit f24ed78c38
28 changed files with 3844 additions and 3 deletions
+11
View File
@@ -10,6 +10,10 @@ import Buckets from './components/Buckets/Buckets';
import Users from './components/Users/Users';
import Policies from './components/Policies/Policies';
import Reports from './components/Reports/Reports';
import Explorer from './components/Explorer/Explorer';
import Aliases from './components/Explorer/Aliases/Aliases';
import Terminal from './components/Explorer/Terminal/Terminal';
import FileBrowser from './components/Explorer/FileBrowser/FileBrowser';
import './i18n';
function App() {
@@ -30,6 +34,13 @@ function App() {
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/buckets" element={<Buckets />} />
<Route path="/explorer" element={<Explorer />}>
<Route index element={<Navigate to="aliases" replace />} />
<Route path="aliases" element={<Aliases />} />
<Route path="terminal" element={<Terminal />} />
<Route path="browse/:alias/:bucket" element={<FileBrowser />} />
<Route path="browse/:alias/:bucket/*" element={<FileBrowser />} />
</Route>
<Route path="/users" element={<Users />} />
<Route path="/policies" element={<Policies />} />
<Route path="/reports" element={<Reports />} />
@@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Alert,
CircularProgress,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, Alias } from '../../../store/explorerStore';
interface AliasDialogProps {
open: boolean;
alias: Alias | null;
onClose: () => void;
onSuccess: () => void;
}
const AliasDialog: React.FC<AliasDialogProps> = ({ open, alias, onClose, onSuccess }) => {
const { t } = useTranslation(['explorer', 'common']);
const { addAlias, updateAlias } = useExplorerStore();
const [aliasName, setAliasName] = useState('');
const [endpoint, setEndpoint] = useState('');
const [accessKey, setAccessKey] = useState('');
const [secretKey, setSecretKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEdit = !!alias;
useEffect(() => {
if (open) {
if (alias) {
setAliasName(alias.alias);
setEndpoint(alias.URL);
setAccessKey('');
setSecretKey('');
} else {
setAliasName('');
setEndpoint('');
setAccessKey('');
setSecretKey('');
}
setError('');
}
}, [open, alias]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validation
if (!aliasName.trim()) {
setError('Alias name is required');
return;
}
if (!endpoint.trim()) {
setError('Endpoint is required');
return;
}
if (!accessKey.trim()) {
setError('Access key is required');
return;
}
if (!secretKey.trim()) {
setError('Secret key is required');
return;
}
setLoading(true);
try {
if (isEdit) {
await updateAlias(aliasName, endpoint, accessKey, secretKey);
} else {
await addAlias(aliasName, endpoint, accessKey, secretKey);
}
onSuccess();
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Operation failed');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>
{t('explorer:aliases.dialog.title')}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{error && (
<Alert severity="error" onClose={() => setError('')}>
{error}
</Alert>
)}
<TextField
label={t('explorer:aliases.dialog.aliasName')}
value={aliasName}
onChange={(e) => setAliasName(e.target.value)}
disabled={isEdit}
required
fullWidth
helperText={t('explorer:aliases.dialog.aliasNameHelper')}
inputProps={{ pattern: '[a-zA-Z0-9_-]+' }}
/>
<TextField
label={t('explorer:aliases.dialog.endpoint')}
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
fullWidth
placeholder="https://minio.example.com"
helperText={t('explorer:aliases.dialog.endpointHelper')}
/>
<TextField
label={t('explorer:aliases.dialog.accessKey')}
value={accessKey}
onChange={(e) => setAccessKey(e.target.value)}
required
fullWidth
/>
<TextField
label={t('explorer:aliases.dialog.secretKey')}
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
required
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
{t('common:actions.cancel')}
</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
{isEdit ? t('common:actions.save') : t('common:actions.create')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default AliasDialog;
@@ -0,0 +1,309 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Chip,
Typography,
Tooltip,
CircularProgress,
Snackbar,
Alert,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
NetworkCheck as TestIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, Alias } from '../../../store/explorerStore';
import AliasDialog from './AliasDialog';
import ConfirmDialog from '../../shared/ConfirmDialog';
import api from '../../../services/api';
const Aliases: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const navigate = useNavigate();
const { aliases, aliasesLoading, loadAliases, removeAlias, testConnection } = useExplorerStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingAlias, setEditingAlias] = useState<Alias | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [aliasToDelete, setAliasToDelete] = useState<string | null>(null);
const [testingAlias, setTestingAlias] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
useEffect(() => {
loadAliases();
}, [loadAliases]);
const handleAddAlias = () => {
setEditingAlias(null);
setDialogOpen(true);
};
const handleEditAlias = (alias: Alias) => {
setEditingAlias(alias);
setDialogOpen(true);
};
const handleDeleteClick = (aliasName: string) => {
setAliasToDelete(aliasName);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
if (aliasToDelete) {
try {
await removeAlias(aliasToDelete);
setSnackbar({
open: true,
message: t('explorer:aliases.messages.deleted'),
severity: 'success',
});
} catch (error: any) {
setSnackbar({
open: true,
message: error.response?.data?.message || 'Delete failed',
severity: 'error',
});
}
}
setDeleteDialogOpen(false);
setAliasToDelete(null);
};
const handleTestConnection = async (aliasName: string) => {
setTestingAlias(aliasName);
try {
const result = await testConnection(aliasName);
setSnackbar({
open: true,
message: result.status === 'connected'
? t('explorer:aliases.messages.testSuccess')
: t('explorer:aliases.messages.testFailed'),
severity: result.status === 'connected' ? 'success' : 'error',
});
// Reload to update status
loadAliases();
} catch (error) {
setSnackbar({
open: true,
message: t('explorer:aliases.messages.testFailed'),
severity: 'error',
});
}
setTestingAlias(null);
};
const handleAliasClick = async (alias: Alias) => {
// Fetch buckets for this alias and navigate to first one, or show bucket picker
try {
const response = await api.get<{ buckets: { key: string }[] }>('/buckets');
if (response.data.buckets && response.data.buckets.length > 0) {
// Navigate to first bucket
navigate(`/explorer/browse/${alias.alias}/${response.data.buckets[0].key}`);
} else {
setSnackbar({
open: true,
message: 'No buckets found for this alias',
severity: 'error',
});
}
} catch (error: any) {
setSnackbar({
open: true,
message: error.response?.data?.message || 'Failed to load buckets',
severity: 'error',
});
}
};
const handleDialogSuccess = () => {
setDialogOpen(false);
loadAliases();
setSnackbar({
open: true,
message: editingAlias
? t('explorer:aliases.messages.updated')
: t('explorer:aliases.messages.created'),
severity: 'success',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'connected':
return 'success';
case 'disconnected':
return 'error';
default:
return 'default';
}
};
if (aliasesLoading && aliases.length === 0) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">{t('explorer:aliases.title')}</Typography>
<Box>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => loadAliases()}
sx={{ mr: 1 }}
>
{t('common:actions.refresh')}
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddAlias}
>
{t('explorer:aliases.addAlias')}
</Button>
</Box>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t('explorer:aliases.columns.name')}</TableCell>
<TableCell>{t('explorer:aliases.columns.endpoint')}</TableCell>
<TableCell>{t('explorer:aliases.columns.status')}</TableCell>
<TableCell align="right">{t('explorer:aliases.columns.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{aliases.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Box py={4}>
<Typography variant="h6" color="text.secondary">
{t('explorer:aliases.empty.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:aliases.empty.message')}
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
aliases.map((alias) => (
<TableRow
key={alias.alias}
hover
sx={{ cursor: 'pointer' }}
onClick={() => handleAliasClick(alias)}
>
<TableCell>
<Typography variant="body1" fontWeight="medium">
{alias.alias}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{alias.URL}
</Typography>
</TableCell>
<TableCell>
<Chip
label={t(`explorer:aliases.status.${alias.status}`)}
color={getStatusColor(alias.status)}
size="small"
/>
</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('explorer:aliases.testConnection')}>
<IconButton
size="small"
onClick={() => handleTestConnection(alias.alias)}
disabled={testingAlias === alias.alias}
>
{testingAlias === alias.alias ? (
<CircularProgress size={20} />
) : (
<TestIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:aliases.editAlias')}>
<IconButton
size="small"
onClick={() => handleEditAlias(alias)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:aliases.deleteAlias')}>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(alias.alias)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<AliasDialog
open={dialogOpen}
alias={editingAlias}
onClose={() => setDialogOpen(false)}
onSuccess={handleDialogSuccess}
/>
<ConfirmDialog
open={deleteDialogOpen}
title={t('explorer:aliases.deleteAlias')}
message={t('common:confirmDialog.deleteMessage', { item: aliasToDelete })}
confirmText={t('common:actions.delete')}
confirmColor="error"
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteDialogOpen(false)}
/>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};
export default Aliases;
@@ -0,0 +1,62 @@
import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Box, Typography, Tabs, Tab, Paper } from '@mui/material';
import {
Storage as StorageIcon,
Terminal as TerminalIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
const Explorer: React.FC = () => {
const { t } = useTranslation(['explorer']);
const navigate = useNavigate();
const location = useLocation();
// Determine current tab based on URL
const getCurrentTab = () => {
if (location.pathname.includes('/terminal')) return 1;
if (location.pathname.includes('/browse')) return -1; // Hide tabs when browsing
return 0; // aliases
};
const currentTab = getCurrentTab();
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
if (newValue === 0) navigate('/explorer/aliases');
else if (newValue === 1) navigate('/explorer/terminal');
};
return (
<Box>
<Typography variant="h4" sx={{ mb: 3 }}>
{t('explorer:title')}
</Typography>
{currentTab !== -1 && (
<Paper sx={{ mb: 3 }}>
<Tabs
value={currentTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
>
<Tab
icon={<StorageIcon />}
iconPosition="start"
label={t('explorer:tabs.aliases')}
/>
<Tab
icon={<TerminalIcon />}
iconPosition="start"
label={t('explorer:tabs.terminal')}
/>
</Tabs>
</Paper>
)}
<Outlet />
</Box>
);
};
export default Explorer;
@@ -0,0 +1,103 @@
import React from 'react';
import { Breadcrumbs, Link, Typography, Box } from '@mui/material';
import {
NavigateNext as NavigateNextIcon,
Storage as StorageIcon,
Home as HomeIcon,
} from '@mui/icons-material';
interface BreadcrumbNavProps {
alias: string;
bucket: string;
path: string;
onNavigate: (path: string) => void;
}
const BreadcrumbNav: React.FC<BreadcrumbNavProps> = ({
alias,
bucket,
path,
onNavigate,
}) => {
const pathParts = path ? path.split('/').filter(Boolean) : [];
const handleClick = (index: number) => {
if (index === -1) {
// Navigate to bucket root
onNavigate('');
} else {
// Navigate to specific path
const newPath = pathParts.slice(0, index + 1).join('/');
onNavigate(newPath);
}
};
return (
<Box sx={{ mb: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
>
{/* Alias link - goes back to aliases list */}
<Link
component="button"
variant="body1"
onClick={() => window.location.href = '/explorer/aliases'}
underline="hover"
color="inherit"
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<HomeIcon sx={{ mr: 0.5 }} fontSize="small" />
{alias}
</Link>
{/* Bucket link */}
{pathParts.length > 0 ? (
<Link
component="button"
variant="body1"
onClick={() => handleClick(-1)}
underline="hover"
color="inherit"
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
{bucket}
</Link>
) : (
<Typography
color="text.primary"
sx={{ display: 'flex', alignItems: 'center' }}
>
<StorageIcon sx={{ mr: 0.5 }} fontSize="small" />
{bucket}
</Typography>
)}
{/* Path parts */}
{pathParts.map((part, index) => {
const isLast = index === pathParts.length - 1;
return isLast ? (
<Typography key={index} color="text.primary">
{part}
</Typography>
) : (
<Link
key={index}
component="button"
variant="body1"
onClick={() => handleClick(index)}
underline="hover"
color="inherit"
sx={{ cursor: 'pointer' }}
>
{part}
</Link>
);
})}
</Breadcrumbs>
</Box>
);
};
export default BreadcrumbNav;
@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
interface CreateFolderDialogProps {
open: boolean;
onClose: () => void;
onConfirm: (name: string) => void;
}
const CreateFolderDialog: React.FC<CreateFolderDialogProps> = ({
open,
onClose,
onConfirm,
}) => {
const { t } = useTranslation(['explorer', 'common']);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName('');
setError('');
}
}, [open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError(t('explorer:browser.createFolderDialog.errors.required'));
return;
}
// Validate folder name
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
setError(t('explorer:browser.createFolderDialog.errors.invalidChars'));
return;
}
if (trimmedName.startsWith('.')) {
setError(t('explorer:browser.createFolderDialog.errors.dotStart'));
return;
}
onConfirm(trimmedName);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>{t('explorer:browser.createFolderDialog.title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t('explorer:browser.createFolderDialog.nameLabel')}
fullWidth
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
error={!!error}
helperText={error || t('explorer:browser.createFolderDialog.nameHelper')}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
<Button type="submit" variant="contained">
{t('common:actions.create')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default CreateFolderDialog;
@@ -0,0 +1,480 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
Typography,
Tooltip,
CircularProgress,
Snackbar,
Alert,
Button,
Checkbox,
Toolbar,
} from '@mui/material';
import {
Folder as FolderIcon,
InsertDriveFile as FileIcon,
Download as DownloadIcon,
Delete as DeleteIcon,
Edit as EditIcon,
CreateNewFolder as CreateFolderIcon,
CloudUpload as UploadIcon,
Refresh as RefreshIcon,
ArrowBack as BackIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useExplorerStore, FileEntry } from '../../../store/explorerStore';
import BreadcrumbNav from './BreadcrumbNav';
import UploadDialog from './UploadDialog';
import CreateFolderDialog from './CreateFolderDialog';
import RenameDialog from './RenameDialog';
import ConfirmDialog from '../../shared/ConfirmDialog';
const FileBrowser: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const { alias, bucket, '*': pathParam } = useParams();
const navigate = useNavigate();
const {
files,
filesLoading,
loadFiles,
downloadFile,
deleteFile,
renameFile,
createFolder,
uploadFiles,
} = useExplorerStore();
const currentPath = pathParam || '';
const [selected, setSelected] = useState<string[]>([]);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToRename, setItemToRename] = useState<FileEntry | null>(null);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
useEffect(() => {
if (alias && bucket) {
loadFiles(alias, bucket, currentPath);
setSelected([]);
}
}, [alias, bucket, currentPath, loadFiles]);
const handleNavigate = useCallback((path: string) => {
if (alias && bucket) {
const newPath = path ? `/explorer/browse/${alias}/${bucket}/${path}` : `/explorer/browse/${alias}/${bucket}`;
navigate(newPath);
}
}, [alias, bucket, navigate]);
const handleItemClick = useCallback((item: FileEntry) => {
if (item.type === 'folder') {
const newPath = currentPath ? `${currentPath}/${item.name}` : item.name;
handleNavigate(newPath);
}
}, [currentPath, handleNavigate]);
const handleBack = useCallback(() => {
if (currentPath) {
const parts = currentPath.split('/');
parts.pop();
handleNavigate(parts.join('/'));
} else {
navigate('/explorer/aliases');
}
}, [currentPath, handleNavigate, navigate]);
const handleSelectAll = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelected(files.map((f) => f.key));
} else {
setSelected([]);
}
}, [files]);
const handleSelect = useCallback((key: string) => {
setSelected((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
}, []);
const handleDownload = useCallback(async (item: FileEntry) => {
if (!alias || !bucket) return;
try {
await downloadFile(alias, bucket, item.key);
setSnackbar({
open: true,
message: t('explorer:browser.messages.downloadStarted'),
severity: 'success',
});
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.downloadFailed'),
severity: 'error',
});
}
}, [alias, bucket, downloadFile, t]);
const handleRenameClick = useCallback((item: FileEntry) => {
setItemToRename(item);
setRenameDialogOpen(true);
}, []);
const handleRenameConfirm = useCallback(async (newName: string) => {
if (!alias || !bucket || !itemToRename) return;
try {
await renameFile(alias, bucket, itemToRename.key, newName);
setSnackbar({
open: true,
message: t('explorer:browser.messages.renamed'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.renameFailed'),
severity: 'error',
});
}
setRenameDialogOpen(false);
setItemToRename(null);
}, [alias, bucket, itemToRename, renameFile, loadFiles, currentPath, t]);
const handleDeleteClick = useCallback((key: string) => {
setItemToDelete(key);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!alias || !bucket || !itemToDelete) return;
try {
await deleteFile(alias, bucket, itemToDelete);
setSnackbar({
open: true,
message: t('explorer:browser.messages.deleted'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.deleteFailed'),
severity: 'error',
});
}
setDeleteDialogOpen(false);
setItemToDelete(null);
}, [alias, bucket, itemToDelete, deleteFile, loadFiles, currentPath, t]);
const handleBulkDelete = useCallback(async () => {
if (!alias || !bucket || selected.length === 0) return;
try {
for (const key of selected) {
await deleteFile(alias, bucket, key);
}
setSnackbar({
open: true,
message: t('explorer:browser.messages.deleted'),
severity: 'success',
});
setSelected([]);
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.deleteFailed'),
severity: 'error',
});
}
}, [alias, bucket, selected, deleteFile, loadFiles, currentPath, t]);
const handleCreateFolder = useCallback(async (name: string) => {
if (!alias || !bucket) return;
try {
const folderPath = currentPath ? `${currentPath}/${name}` : name;
await createFolder(alias, bucket, folderPath);
setSnackbar({
open: true,
message: t('explorer:browser.messages.folderCreated'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.folderCreateFailed'),
severity: 'error',
});
}
setCreateFolderDialogOpen(false);
}, [alias, bucket, currentPath, createFolder, loadFiles, t]);
const handleUpload = useCallback(async (uploadedFiles: File[]) => {
if (!alias || !bucket) return;
try {
await uploadFiles(alias, bucket, uploadedFiles, currentPath);
setSnackbar({
open: true,
message: t('explorer:browser.messages.uploaded'),
severity: 'success',
});
loadFiles(alias, bucket, currentPath);
} catch (error: any) {
setSnackbar({
open: true,
message: error.message || t('explorer:browser.messages.uploadFailed'),
severity: 'error',
});
}
setUploadDialogOpen(false);
}, [alias, bucket, currentPath, uploadFiles, loadFiles, t]);
const formatSize = (bytes: number): string => {
if (bytes === 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
const formatDate = (date: string | null): string => {
if (!date) return '-';
return new Date(date).toLocaleString();
};
if (!alias || !bucket) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<Typography color="error">{t('explorer:browser.errors.missingParams')}</Typography>
</Box>
);
}
return (
<Box>
{/* Breadcrumb navigation */}
<BreadcrumbNav
alias={alias}
bucket={bucket}
path={currentPath}
onNavigate={handleNavigate}
/>
{/* Toolbar */}
<Toolbar
sx={{
pl: { sm: 2 },
pr: { xs: 1, sm: 1 },
bgcolor: selected.length > 0 ? 'action.selected' : 'transparent',
mb: 2,
}}
>
{selected.length > 0 ? (
<>
<Typography sx={{ flex: '1 1 100%' }} color="inherit" variant="subtitle1">
{t('explorer:browser.selected', { count: selected.length })}
</Typography>
<Tooltip title={t('common:actions.delete')}>
<IconButton onClick={handleBulkDelete} color="error">
<DeleteIcon />
</IconButton>
</Tooltip>
</>
) : (
<>
<Tooltip title={t('common:actions.back')}>
<IconButton onClick={handleBack} sx={{ mr: 1 }}>
<BackIcon />
</IconButton>
</Tooltip>
<Typography sx={{ flex: '1 1 100%' }} variant="h6">
{bucket}
</Typography>
<Tooltip title={t('common:actions.refresh')}>
<IconButton onClick={() => loadFiles(alias, bucket, currentPath)}>
<RefreshIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('explorer:browser.createFolder')}>
<IconButton onClick={() => setCreateFolderDialogOpen(true)}>
<CreateFolderIcon />
</IconButton>
</Tooltip>
<Button
variant="contained"
startIcon={<UploadIcon />}
onClick={() => setUploadDialogOpen(true)}
sx={{ ml: 1 }}
>
{t('explorer:browser.upload')}
</Button>
</>
)}
</Toolbar>
{/* File table */}
{filesLoading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={selected.length > 0 && selected.length < files.length}
checked={files.length > 0 && selected.length === files.length}
onChange={handleSelectAll}
/>
</TableCell>
<TableCell>{t('explorer:browser.columns.name')}</TableCell>
<TableCell>{t('explorer:browser.columns.size')}</TableCell>
<TableCell>{t('explorer:browser.columns.type')}</TableCell>
<TableCell>{t('explorer:browser.columns.lastModified')}</TableCell>
<TableCell align="right">{t('explorer:browser.columns.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Box py={4}>
<Typography variant="h6" color="text.secondary">
{t('explorer:browser.empty.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:browser.empty.message')}
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
files.map((item) => (
<TableRow
key={item.key}
hover
sx={{ cursor: item.type === 'folder' ? 'pointer' : 'default' }}
onClick={() => handleItemClick(item)}
>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.includes(item.key)}
onChange={() => handleSelect(item.key)}
/>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
{item.type === 'folder' ? (
<FolderIcon color="primary" />
) : (
<FileIcon color="action" />
)}
<Typography variant="body2">{item.name}</Typography>
</Box>
</TableCell>
<TableCell>{formatSize(item.size)}</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{item.type === 'folder' ? t('explorer:browser.types.folder') : (item.contentType || t('explorer:browser.types.file'))}
</Typography>
</TableCell>
<TableCell>{formatDate(item.lastModified)}</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{item.type !== 'folder' && (
<Tooltip title={t('explorer:browser.download')}>
<IconButton size="small" onClick={() => handleDownload(item)}>
<DownloadIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={t('explorer:browser.rename')}>
<IconButton size="small" onClick={() => handleRenameClick(item)}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('common:actions.delete')}>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(item.key)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
{/* Dialogs */}
<UploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
onUpload={handleUpload}
/>
<CreateFolderDialog
open={createFolderDialogOpen}
onClose={() => setCreateFolderDialogOpen(false)}
onConfirm={handleCreateFolder}
/>
<RenameDialog
open={renameDialogOpen}
currentName={itemToRename?.name || ''}
onClose={() => {
setRenameDialogOpen(false);
setItemToRename(null);
}}
onConfirm={handleRenameConfirm}
/>
<ConfirmDialog
open={deleteDialogOpen}
title={t('explorer:browser.deleteItem')}
message={t('common:confirmDialog.deleteMessage', { item: itemToDelete?.split('/').pop() || '' })}
confirmText={t('common:actions.delete')}
confirmColor="error"
onConfirm={handleDeleteConfirm}
onCancel={() => {
setDeleteDialogOpen(false);
setItemToDelete(null);
}}
/>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar({ ...snackbar, open: false })}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};
export default FileBrowser;
@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
interface RenameDialogProps {
open: boolean;
currentName: string;
onClose: () => void;
onConfirm: (newName: string) => void;
}
const RenameDialog: React.FC<RenameDialogProps> = ({
open,
currentName,
onClose,
onConfirm,
}) => {
const { t } = useTranslation(['explorer', 'common']);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName(currentName);
setError('');
}
}, [open, currentName]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError(t('explorer:browser.renameDialog.errors.required'));
return;
}
if (trimmedName === currentName) {
setError(t('explorer:browser.renameDialog.errors.same'));
return;
}
// Validate name
if (trimmedName.includes('/') || trimmedName.includes('\\')) {
setError(t('explorer:browser.renameDialog.errors.invalidChars'));
return;
}
onConfirm(trimmedName);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>{t('explorer:browser.renameDialog.title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t('explorer:browser.renameDialog.newNameLabel')}
fullWidth
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
error={!!error}
helperText={error}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('common:actions.cancel')}</Button>
<Button type="submit" variant="contained">
{t('explorer:browser.rename')}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default RenameDialog;
@@ -0,0 +1,197 @@
import React, { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
List,
ListItem,
ListItemIcon,
ListItemText,
IconButton,
LinearProgress,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
InsertDriveFile as FileIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
interface UploadDialogProps {
open: boolean;
onClose: () => void;
onUpload: (files: File[]) => Promise<void>;
}
const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, onUpload }) => {
const { t } = useTranslation(['explorer', 'common']);
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [...prev, ...droppedFiles]);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [...prev, ...selectedFiles]);
}
}, []);
const handleRemoveFile = useCallback((index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpload = useCallback(async () => {
if (files.length === 0) return;
setUploading(true);
try {
await onUpload(files);
setFiles([]);
} finally {
setUploading(false);
}
}, [files, onUpload]);
const handleClose = useCallback(() => {
if (!uploading) {
setFiles([]);
onClose();
}
}, [uploading, onClose]);
const formatSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('explorer:browser.uploadDialog.title')}</DialogTitle>
<DialogContent>
{/* Drop zone */}
<Box
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
sx={{
border: 2,
borderStyle: 'dashed',
borderColor: dragOver ? 'primary.main' : 'grey.400',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: dragOver ? 'action.hover' : 'transparent',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
},
}}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<UploadIcon sx={{ fontSize: 48, color: 'grey.500', mb: 1 }} />
<Typography variant="h6" color="text.secondary">
{t('explorer:browser.uploadDialog.dropzone')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t('explorer:browser.uploadDialog.or')}
</Typography>
<Button variant="outlined" sx={{ mt: 1 }}>
{t('explorer:browser.uploadDialog.browse')}
</Button>
</Box>
{/* File list */}
{files.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t('explorer:browser.uploadDialog.selectedFiles', { count: files.length })}
</Typography>
<List dense sx={{ maxHeight: 200, overflow: 'auto' }}>
{files.map((file, index) => (
<ListItem
key={index}
secondaryAction={
<IconButton
edge="end"
size="small"
onClick={() => handleRemoveFile(index)}
disabled={uploading}
>
<DeleteIcon />
</IconButton>
}
>
<ListItemIcon>
<FileIcon />
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={formatSize(file.size)}
/>
</ListItem>
))}
</List>
</Box>
)}
{/* Upload progress */}
{uploading && (
<Box sx={{ mt: 2 }}>
<LinearProgress />
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 1 }}>
{t('explorer:browser.uploadDialog.uploading')}
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={uploading}>
{t('common:actions.cancel')}
</Button>
<Button
variant="contained"
onClick={handleUpload}
disabled={files.length === 0 || uploading}
startIcon={<UploadIcon />}
>
{t('explorer:browser.upload')}
</Button>
</DialogActions>
</Dialog>
);
};
export default UploadDialog;
@@ -0,0 +1,214 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Box,
TextField,
IconButton,
Typography,
Paper,
Tooltip,
CircularProgress,
} from '@mui/material';
import {
Clear as ClearIcon,
Send as SendIcon,
} from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { useTerminalStore } from '../../../store/terminalStore';
const Terminal: React.FC = () => {
const { t } = useTranslation(['explorer', 'common']);
const {
commandHistory,
outputHistory,
isExecuting,
executeCommand,
clearOutput,
} = useTerminalStore();
const [inputValue, setInputValue] = useState('');
const [historyIndex, setHistoryIndex] = useState(-1);
const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when output changes
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [outputHistory]);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
const command = inputValue.trim();
if (!command || isExecuting) return;
setInputValue('');
setHistoryIndex(-1);
await executeCommand(command);
}, [inputValue, isExecuting, executeCommand]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
if (commandHistory.length === 0) return;
const newIndex = historyIndex < commandHistory.length - 1
? historyIndex + 1
: historyIndex;
setHistoryIndex(newIndex);
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex <= 0) {
setHistoryIndex(-1);
setInputValue('');
} else {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setInputValue(commandHistory[commandHistory.length - 1 - newIndex] || '');
}
}
}, [commandHistory, historyIndex]);
const handleClear = useCallback(() => {
clearOutput();
inputRef.current?.focus();
}, [clearOutput]);
return (
<Box sx={{ height: 'calc(100vh - 250px)', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">{t('explorer:terminal.title')}</Typography>
<Tooltip title={t('explorer:terminal.clear')}>
<IconButton onClick={handleClear} size="small">
<ClearIcon />
</IconButton>
</Tooltip>
</Box>
<Paper
sx={{
flex: 1,
bgcolor: 'grey.900',
color: 'grey.100',
fontFamily: 'monospace',
fontSize: '14px',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Output area */}
<Box
ref={outputRef}
sx={{
flex: 1,
overflow: 'auto',
p: 2,
'& pre': {
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
}}
>
{outputHistory.length === 0 ? (
<Typography
variant="body2"
sx={{ color: 'grey.500', fontFamily: 'monospace' }}
>
{t('explorer:terminal.welcomeMessage')}
</Typography>
) : (
outputHistory.map((entry) => (
<Box key={entry.id} sx={{ mb: 2 }}>
<Box sx={{ color: 'primary.light', mb: 0.5 }}>
<span style={{ color: '#4caf50' }}>$</span> {entry.command}
</Box>
{entry.output && (
<pre style={{ color: '#e0e0e0' }}>{entry.output}</pre>
)}
{entry.error && (
<pre style={{ color: '#f44336' }}>{entry.error}</pre>
)}
</Box>
))
)}
{isExecuting && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'grey.500' }}>
<CircularProgress size={16} color="inherit" />
<span>{t('explorer:terminal.executing')}</span>
</Box>
)}
</Box>
{/* Input area */}
<Box
component="form"
onSubmit={handleSubmit}
sx={{
display: 'flex',
alignItems: 'center',
borderTop: 1,
borderColor: 'grey.800',
p: 1,
bgcolor: 'grey.800',
}}
>
<Typography
component="span"
sx={{ color: '#4caf50', mr: 1, fontFamily: 'monospace' }}
>
$
</Typography>
<TextField
inputRef={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isExecuting}
placeholder={t('explorer:terminal.placeholder')}
variant="standard"
fullWidth
autoComplete="off"
InputProps={{
disableUnderline: true,
sx: {
fontFamily: 'monospace',
fontSize: '14px',
color: 'grey.100',
'& input::placeholder': {
color: 'grey.600',
opacity: 1,
},
},
}}
/>
<Tooltip title={t('explorer:terminal.execute')}>
<span>
<IconButton
type="submit"
disabled={isExecuting || !inputValue.trim()}
size="small"
sx={{ color: 'grey.400' }}
>
<SendIcon />
</IconButton>
</span>
</Tooltip>
</Box>
</Paper>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
{t('explorer:terminal.helpText')}
</Typography>
</Box>
);
};
export default Terminal;
+3 -1
View File
@@ -25,6 +25,7 @@ import {
ChevronLeft as ChevronLeftIcon,
Dashboard as DashboardIcon,
Storage as StorageIcon,
FolderOpen as FolderOpenIcon,
People as PeopleIcon,
Policy as PolicyIcon,
Assessment as AssessmentIcon,
@@ -55,6 +56,7 @@ const Layout: React.FC = () => {
const navItems: NavItem[] = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/', translationKey: 'navigation.dashboard' },
{ text: 'Buckets', icon: <StorageIcon />, path: '/buckets', translationKey: 'navigation.buckets' },
{ text: 'Explorer', icon: <FolderOpenIcon />, path: '/explorer', translationKey: 'navigation.explorer' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users', translationKey: 'navigation.users' },
{ text: 'Policies', icon: <PolicyIcon />, path: '/policies', translationKey: 'navigation.policies' },
{ text: 'Reports', icon: <AssessmentIcon />, path: '/reports', translationKey: 'navigation.reports' },
@@ -224,7 +226,7 @@ const Layout: React.FC = () => {
{navItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
selected={location.pathname === item.path || location.pathname.startsWith(item.path + '/')}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
+5 -1
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Übersicht",
"buckets": "Buckets",
"explorer": "Explorer",
"users": "Benutzer",
"policies": "Richtlinien",
"reports": "Berichte",
+133
View File
@@ -0,0 +1,133 @@
{
"title": "Speicher-Explorer",
"tabs": {
"aliases": "Aliase",
"terminal": "Terminal"
},
"aliases": {
"title": "MinIO Aliase",
"addAlias": "Alias hinzufügen",
"editAlias": "Alias bearbeiten",
"deleteAlias": "Alias löschen",
"testConnection": "Verbindung testen",
"columns": {
"name": "Name",
"endpoint": "Endpunkt",
"status": "Status",
"actions": "Aktionen"
},
"status": {
"connected": "Verbunden",
"disconnected": "Getrennt",
"unknown": "Unbekannt",
"testing": "Teste..."
},
"dialog": {
"title": "Alias-Konfiguration",
"aliasName": "Alias-Name",
"endpoint": "Endpunkt-URL",
"accessKey": "Zugriffsschlüssel",
"secretKey": "Geheimer Schlüssel",
"aliasNameHelper": "Ein eindeutiger Name für diese Verbindung (z.B. produktion, staging)",
"endpointHelper": "MinIO Server-URL (z.B. https://minio.example.com)"
},
"empty": {
"title": "Keine Aliase konfiguriert",
"message": "Fügen Sie einen MinIO-Alias hinzu, um Dateien zu durchsuchen"
},
"messages": {
"created": "Alias erfolgreich erstellt",
"updated": "Alias erfolgreich aktualisiert",
"deleted": "Alias erfolgreich gelöscht",
"testSuccess": "Verbindung erfolgreich",
"testFailed": "Verbindung fehlgeschlagen"
}
},
"terminal": {
"title": "MinIO CLI Terminal",
"placeholder": "mc Befehl eingeben (z.B. mc ls myminio)",
"clear": "Löschen",
"execute": "Ausführen",
"executing": "Wird ausgeführt...",
"running": "Wird ausgeführt...",
"hint": "Pfeiltasten für Befehlsverlauf verwenden",
"welcomeMessage": "Willkommen im MinIO CLI Terminal. Geben Sie 'mc' Befehle ein, um mit Ihren MinIO-Servern zu interagieren.",
"helpText": "Tipp: Verwenden Sie Pfeiltasten hoch/runter für den Befehlsverlauf"
},
"browser": {
"title": "Datei-Browser",
"selectBucket": "Bucket auswählen",
"upload": "Hochladen",
"createFolder": "Ordner erstellen",
"newFolder": "Neuer Ordner",
"download": "Herunterladen",
"delete": "Löschen",
"rename": "Umbenennen",
"copy": "Kopieren",
"refresh": "Aktualisieren",
"deleteItem": "Element löschen",
"selected": "{{count}} Element(e) ausgewählt",
"columns": {
"name": "Name",
"size": "Größe",
"type": "Typ",
"lastModified": "Zuletzt geändert",
"actions": "Aktionen"
},
"types": {
"file": "Datei",
"folder": "Ordner"
},
"empty": {
"title": "Keine Dateien",
"message": "Dieser Ordner ist leer. Laden Sie Dateien hoch oder erstellen Sie einen neuen Ordner."
},
"errors": {
"missingParams": "Fehlender Alias- oder Bucket-Parameter"
},
"uploadDialog": {
"title": "Dateien hochladen",
"dropzone": "Dateien hierher ziehen",
"or": "oder",
"browse": "Dateien durchsuchen",
"selectedFiles": "{{count}} Datei(en) ausgewählt",
"uploading": "Wird hochgeladen..."
},
"createFolderDialog": {
"title": "Ordner erstellen",
"nameLabel": "Ordnername",
"nameHelper": "Geben Sie einen Namen für den neuen Ordner ein",
"errors": {
"required": "Ordnername ist erforderlich",
"invalidChars": "Ordnername darf kein / oder \\ enthalten",
"dotStart": "Ordnername darf nicht mit einem Punkt beginnen"
}
},
"renameDialog": {
"title": "Umbenennen",
"newNameLabel": "Neuer Name",
"errors": {
"required": "Name ist erforderlich",
"same": "Neuer Name muss sich vom aktuellen Namen unterscheiden",
"invalidChars": "Name darf kein / oder \\ enthalten"
}
},
"deleteConfirm": {
"title": "{{name}} löschen?",
"message": "Diese Aktion kann nicht rückgängig gemacht werden.",
"folderWarning": "Dieser Ordner und sein gesamter Inhalt werden dauerhaft gelöscht."
},
"messages": {
"folderCreated": "Ordner erfolgreich erstellt",
"folderCreateFailed": "Ordner konnte nicht erstellt werden",
"renamed": "Element erfolgreich umbenannt",
"renameFailed": "Element konnte nicht umbenannt werden",
"deleted": "Element erfolgreich gelöscht",
"deleteFailed": "Element konnte nicht gelöscht werden",
"downloadStarted": "Download gestartet",
"downloadFailed": "Datei konnte nicht heruntergeladen werden",
"uploaded": "Dateien erfolgreich hochgeladen",
"uploadFailed": "Dateien konnten nicht hochgeladen werden"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
"navigation": {
"dashboard": "Dashboard",
"buckets": "Buckets",
"explorer": "Explorer",
"users": "Users",
"policies": "Policies",
"reports": "Reports",
+133
View File
@@ -0,0 +1,133 @@
{
"title": "Storage Explorer",
"tabs": {
"aliases": "Aliases",
"terminal": "Terminal"
},
"aliases": {
"title": "MinIO Aliases",
"addAlias": "Add Alias",
"editAlias": "Edit Alias",
"deleteAlias": "Delete Alias",
"testConnection": "Test Connection",
"columns": {
"name": "Name",
"endpoint": "Endpoint",
"status": "Status",
"actions": "Actions"
},
"status": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown",
"testing": "Testing..."
},
"dialog": {
"title": "Alias Configuration",
"aliasName": "Alias Name",
"endpoint": "Endpoint URL",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"aliasNameHelper": "A unique name for this connection (e.g., production, staging)",
"endpointHelper": "MinIO server URL (e.g., https://minio.example.com)"
},
"empty": {
"title": "No aliases configured",
"message": "Add a MinIO alias to start browsing files"
},
"messages": {
"created": "Alias created successfully",
"updated": "Alias updated successfully",
"deleted": "Alias deleted successfully",
"testSuccess": "Connection successful",
"testFailed": "Connection failed"
}
},
"terminal": {
"title": "MinIO CLI Terminal",
"placeholder": "Enter mc command (e.g., mc ls myminio)",
"clear": "Clear",
"execute": "Execute",
"executing": "Executing...",
"running": "Running...",
"hint": "Use arrow keys to navigate command history",
"welcomeMessage": "Welcome to MinIO CLI Terminal. Type 'mc' commands to interact with your MinIO servers.",
"helpText": "Tip: Use up/down arrow keys to navigate command history"
},
"browser": {
"title": "File Browser",
"selectBucket": "Select Bucket",
"upload": "Upload",
"createFolder": "Create Folder",
"newFolder": "New Folder",
"download": "Download",
"delete": "Delete",
"rename": "Rename",
"copy": "Copy",
"refresh": "Refresh",
"deleteItem": "Delete Item",
"selected": "{{count}} item(s) selected",
"columns": {
"name": "Name",
"size": "Size",
"type": "Type",
"lastModified": "Last Modified",
"actions": "Actions"
},
"types": {
"file": "File",
"folder": "Folder"
},
"empty": {
"title": "No files",
"message": "This folder is empty. Upload files or create a new folder."
},
"errors": {
"missingParams": "Missing alias or bucket parameter"
},
"uploadDialog": {
"title": "Upload Files",
"dropzone": "Drag and drop files here",
"or": "or",
"browse": "Browse Files",
"selectedFiles": "{{count}} file(s) selected",
"uploading": "Uploading..."
},
"createFolderDialog": {
"title": "Create Folder",
"nameLabel": "Folder Name",
"nameHelper": "Enter a name for the new folder",
"errors": {
"required": "Folder name is required",
"invalidChars": "Folder name cannot contain / or \\",
"dotStart": "Folder name cannot start with a dot"
}
},
"renameDialog": {
"title": "Rename",
"newNameLabel": "New Name",
"errors": {
"required": "Name is required",
"same": "New name must be different from current name",
"invalidChars": "Name cannot contain / or \\"
}
},
"deleteConfirm": {
"title": "Delete {{name}}?",
"message": "This action cannot be undone.",
"folderWarning": "This folder and all its contents will be permanently deleted."
},
"messages": {
"folderCreated": "Folder created successfully",
"folderCreateFailed": "Failed to create folder",
"renamed": "Item renamed successfully",
"renameFailed": "Failed to rename item",
"deleted": "Item deleted successfully",
"deleteFailed": "Failed to delete item",
"downloadStarted": "Download started",
"downloadFailed": "Failed to download file",
"uploaded": "Files uploaded successfully",
"uploadFailed": "Failed to upload files"
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import api from './api';
export interface Alias {
alias: string;
URL: string;
accessKey: string;
status: 'connected' | 'disconnected' | 'unknown';
}
export interface AliasInput {
aliasName: string;
endpoint: string;
accessKey: string;
secretKey: string;
}
export interface ConnectionStatus {
status: string;
alias: string;
error?: string;
}
class AliasService {
async listAliases(): Promise<Alias[]> {
const response = await api.get<{ aliases: Alias[]; count: number }>('/aliases');
return response.data.aliases;
}
async getAlias(name: string): Promise<Alias> {
const response = await api.get<Alias>(`/aliases/${name}`);
return response.data;
}
async addAlias(alias: AliasInput): Promise<void> {
await api.post('/aliases', alias);
}
async updateAlias(name: string, alias: Omit<AliasInput, 'aliasName'>): Promise<void> {
await api.put(`/aliases/${name}`, alias);
}
async removeAlias(name: string): Promise<void> {
await api.delete(`/aliases/${name}`);
}
async testConnection(name: string): Promise<ConnectionStatus> {
const response = await api.post<ConnectionStatus>(`/aliases/${name}/test`);
return response.data;
}
}
export default new AliasService();
+123
View File
@@ -0,0 +1,123 @@
import api from './api';
export interface FileEntry {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
type: 'file' | 'folder';
etag?: string | null;
}
export interface ListFilesResponse {
objects: FileEntry[];
prefix: string;
count: number;
}
export interface FileStat {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
contentType: string;
etag: string | null;
metadata: Record<string, string>;
}
export interface PathSize {
path: string;
size: number;
sizeFormatted: string;
objects: number;
}
class FileService {
async listFiles(alias: string, bucket: string, prefix?: string, recursive?: boolean): Promise<FileEntry[]> {
const response = await api.get<ListFilesResponse>(`/browser/${alias}/${bucket}`, {
params: { prefix, recursive },
});
return response.data.objects;
}
async getFileStat(alias: string, bucket: string, path: string): Promise<FileStat> {
const response = await api.get<FileStat>(`/browser/${alias}/${bucket}/stat/${encodeURIComponent(path)}`);
return response.data;
}
async getPathSize(alias: string, bucket: string, path?: string): Promise<PathSize> {
const url = path
? `/browser/${alias}/${bucket}/size/${encodeURIComponent(path)}`
: `/browser/${alias}/${bucket}/size`;
const response = await api.get<PathSize>(url);
return response.data;
}
async uploadFiles(alias: string, bucket: string, files: File[], prefix?: string): Promise<{
uploaded: { name: string; key: string; size: number }[];
failed: { name: string; error: string }[];
count: number;
}> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await api.post(`/browser/${alias}/${bucket}/upload`, formData, {
params: { prefix },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 min for large uploads
});
return response.data;
}
async deleteFile(alias: string, bucket: string, path: string, recursive?: boolean): Promise<void> {
await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
params: { recursive },
});
}
async renameFile(alias: string, bucket: string, source: string, destination: string): Promise<void> {
await api.post(`/browser/${alias}/${bucket}/rename`, {
source,
destination,
});
}
async copyFile(alias: string, bucket: string, source: string, destination: string): Promise<void> {
await api.post(`/browser/${alias}/${bucket}/copy`, {
source,
destination,
});
}
async createFolder(alias: string, bucket: string, folderName: string, prefix?: string): Promise<void> {
await api.post(
`/browser/${alias}/${bucket}/mkdir`,
{ folderName },
{ params: { prefix } }
);
}
async downloadFile(alias: string, bucket: string, path: string): Promise<void> {
const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, {
responseType: 'blob',
timeout: 300000, // 5 min for large downloads
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = path.split('/').pop() || 'download';
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
}
export default new FileService();
+21
View File
@@ -0,0 +1,21 @@
import api from './api';
export interface CommandResponse {
success: boolean;
output: string;
error: string;
exitCode: number;
executionTime: number;
}
class TerminalService {
async executeCommand(command: string, timeout?: number): Promise<CommandResponse> {
const response = await api.post<CommandResponse>('/terminal/execute', {
command,
timeout,
});
return response.data;
}
}
export default new TerminalService();
+235
View File
@@ -0,0 +1,235 @@
import { create } from 'zustand';
import api from '../services/api';
export interface Alias {
alias: string;
URL: string;
accessKey: string;
status: 'connected' | 'disconnected' | 'unknown';
}
export interface FileEntry {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
type: 'file' | 'folder';
etag?: string | null;
contentType?: string | null;
}
interface ExplorerState {
// Aliases
aliases: Alias[];
aliasesLoading: boolean;
aliasesError: string | null;
// File Browser
currentAlias: string | null;
currentBucket: string | null;
currentPath: string;
files: FileEntry[];
filesLoading: boolean;
filesError: string | null;
selectedFiles: string[];
// Actions - Aliases
loadAliases: () => Promise<void>;
addAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
updateAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
removeAlias: (aliasName: string) => Promise<void>;
testConnection: (aliasName: string) => Promise<{ status: string; error?: string }>;
// Actions - File Browser
setCurrentLocation: (alias: string | null, bucket: string | null, path: string) => void;
loadFiles: (alias: string, bucket: string, prefix?: string) => Promise<void>;
uploadFiles: (alias: string, bucket: string, files: File[], prefix?: string) => Promise<void>;
deleteFile: (alias: string, bucket: string, path: string, recursive?: boolean) => Promise<void>;
renameFile: (alias: string, bucket: string, source: string, destination: string) => Promise<void>;
createFolder: (alias: string, bucket: string, folderName: string, prefix?: string) => Promise<void>;
downloadFile: (alias: string, bucket: string, path: string) => Promise<void>;
// Selection
toggleFileSelection: (key: string) => void;
clearSelection: () => void;
selectAll: () => void;
}
export const useExplorerStore = create<ExplorerState>((set, get) => ({
// Initial state
aliases: [],
aliasesLoading: false,
aliasesError: null,
currentAlias: null,
currentBucket: null,
currentPath: '',
files: [],
filesLoading: false,
filesError: null,
selectedFiles: [],
// Alias Actions
loadAliases: async () => {
set({ aliasesLoading: true, aliasesError: null });
try {
const response = await api.get<{ aliases: Alias[] }>('/aliases');
set({ aliases: response.data.aliases, aliasesLoading: false });
} catch (error: any) {
set({
aliasesError: error.response?.data?.message || 'Failed to load aliases',
aliasesLoading: false,
});
throw error;
}
},
addAlias: async (aliasName, endpoint, accessKey, secretKey) => {
const response = await api.post('/aliases', {
aliasName,
endpoint,
accessKey,
secretKey,
});
// Reload aliases after adding
await get().loadAliases();
return response.data;
},
updateAlias: async (aliasName, endpoint, accessKey, secretKey) => {
await api.put(`/aliases/${aliasName}`, {
endpoint,
accessKey,
secretKey,
});
await get().loadAliases();
},
removeAlias: async (aliasName) => {
await api.delete(`/aliases/${aliasName}`);
await get().loadAliases();
},
testConnection: async (aliasName) => {
const response = await api.post<{ status: string; error?: string }>(`/aliases/${aliasName}/test`);
return response.data;
},
// File Browser Actions
setCurrentLocation: (alias, bucket, path) => {
set({
currentAlias: alias,
currentBucket: bucket,
currentPath: path,
selectedFiles: [],
});
},
loadFiles: async (alias, bucket, prefix = '') => {
set({ filesLoading: true, filesError: null, selectedFiles: [] });
try {
const response = await api.get<{ objects: FileEntry[]; prefix: string; count: number }>(
`/browser/${alias}/${bucket}`,
{ params: { prefix } }
);
set({
files: response.data.objects,
currentPath: prefix,
filesLoading: false,
});
} catch (error: any) {
set({
filesError: error.response?.data?.message || 'Failed to load files',
filesLoading: false,
files: [],
});
throw error;
}
},
uploadFiles: async (alias, bucket, files, prefix = '') => {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
await api.post(`/browser/${alias}/${bucket}/upload`, formData, {
params: { prefix },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 min for large uploads
});
// Reload files after upload
await get().loadFiles(alias, bucket, prefix);
},
deleteFile: async (alias, bucket, path, recursive = false) => {
await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
params: { recursive },
});
// Reload files after delete
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
renameFile: async (alias, bucket, source, destination) => {
await api.post(`/browser/${alias}/${bucket}/rename`, {
source,
destination,
});
// Reload files after rename
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
createFolder: async (alias, bucket, folderName, prefix = '') => {
await api.post(
`/browser/${alias}/${bucket}/mkdir`,
{ folderName },
{ params: { prefix } }
);
// Reload files after creating folder
await get().loadFiles(alias, bucket, prefix);
},
downloadFile: async (alias, bucket, path) => {
const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, {
responseType: 'blob',
timeout: 300000, // 5 min for large downloads
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = path.split('/').pop() || 'download';
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// Selection Actions
toggleFileSelection: (key) => {
set((state) => {
const isSelected = state.selectedFiles.includes(key);
return {
selectedFiles: isSelected
? state.selectedFiles.filter((k) => k !== key)
: [...state.selectedFiles, key],
};
});
},
clearSelection: () => {
set({ selectedFiles: [] });
},
selectAll: () => {
set((state) => ({
selectedFiles: state.files.map((f) => f.key),
}));
},
}));
+128
View File
@@ -0,0 +1,128 @@
import { create } from 'zustand';
import api from '../services/api';
export interface OutputEntry {
id: string;
command: string;
output: string;
error: string;
isError: boolean;
timestamp: Date;
executionTime: number;
}
interface TerminalState {
commandHistory: string[];
outputHistory: OutputEntry[];
currentCommand: string;
historyIndex: number;
isExecuting: boolean;
// Actions
executeCommand: (command: string) => Promise<void>;
clearOutput: () => void;
navigateHistory: (direction: 'up' | 'down') => void;
setCurrentCommand: (command: string) => void;
}
export const useTerminalStore = create<TerminalState>((set, get) => ({
commandHistory: [],
outputHistory: [],
currentCommand: '',
historyIndex: -1,
isExecuting: false,
executeCommand: async (command: string) => {
if (!command.trim()) return;
const state = get();
// Add to command history (avoid duplicates at the end)
const newHistory = [...state.commandHistory];
if (newHistory[newHistory.length - 1] !== command) {
newHistory.push(command);
}
set({
isExecuting: true,
currentCommand: '',
commandHistory: newHistory,
historyIndex: -1,
});
try {
const response = await api.post<{
success: boolean;
output: string;
error: string;
exitCode: number;
executionTime: number;
}>('/terminal/execute', { command });
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: response.data.output,
error: response.data.error,
isError: !response.data.success,
timestamp: new Date(),
executionTime: response.data.executionTime,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
} catch (error: any) {
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: '',
error: error.response?.data?.message || error.message || 'Command execution failed',
isError: true,
timestamp: new Date(),
executionTime: 0,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
}
},
clearOutput: () => {
set({ outputHistory: [] });
},
navigateHistory: (direction: 'up' | 'down') => {
const state = get();
const { commandHistory, historyIndex } = state;
if (commandHistory.length === 0) return;
let newIndex: number;
if (direction === 'up') {
newIndex = historyIndex === -1
? commandHistory.length - 1
: Math.max(0, historyIndex - 1);
} else {
newIndex = historyIndex === -1
? -1
: Math.min(commandHistory.length - 1, historyIndex + 1);
if (historyIndex === commandHistory.length - 1) {
newIndex = -1;
}
}
set({
historyIndex: newIndex,
currentCommand: newIndex === -1 ? '' : commandHistory[newIndex],
});
},
setCurrentCommand: (command: string) => {
set({ currentCommand: command, historyIndex: -1 });
},
}));