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:
@@ -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