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