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

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

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

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

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

Frontend:
- New Explorer component with Aliases/Terminal tabs
- Zustand stores for terminal and explorer state
- i18n translations (English and German)
This commit is contained in:
Paul Nothaft
2026-01-04 22:19:03 +01:00
parent feae9882bc
commit f24ed78c38
28 changed files with 3844 additions and 3 deletions
+235
View File
@@ -0,0 +1,235 @@
import { create } from 'zustand';
import api from '../services/api';
export interface Alias {
alias: string;
URL: string;
accessKey: string;
status: 'connected' | 'disconnected' | 'unknown';
}
export interface FileEntry {
key: string;
name: string;
size: number;
sizeFormatted: string;
lastModified: string | null;
type: 'file' | 'folder';
etag?: string | null;
contentType?: string | null;
}
interface ExplorerState {
// Aliases
aliases: Alias[];
aliasesLoading: boolean;
aliasesError: string | null;
// File Browser
currentAlias: string | null;
currentBucket: string | null;
currentPath: string;
files: FileEntry[];
filesLoading: boolean;
filesError: string | null;
selectedFiles: string[];
// Actions - Aliases
loadAliases: () => Promise<void>;
addAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
updateAlias: (aliasName: string, endpoint: string, accessKey: string, secretKey: string) => Promise<void>;
removeAlias: (aliasName: string) => Promise<void>;
testConnection: (aliasName: string) => Promise<{ status: string; error?: string }>;
// Actions - File Browser
setCurrentLocation: (alias: string | null, bucket: string | null, path: string) => void;
loadFiles: (alias: string, bucket: string, prefix?: string) => Promise<void>;
uploadFiles: (alias: string, bucket: string, files: File[], prefix?: string) => Promise<void>;
deleteFile: (alias: string, bucket: string, path: string, recursive?: boolean) => Promise<void>;
renameFile: (alias: string, bucket: string, source: string, destination: string) => Promise<void>;
createFolder: (alias: string, bucket: string, folderName: string, prefix?: string) => Promise<void>;
downloadFile: (alias: string, bucket: string, path: string) => Promise<void>;
// Selection
toggleFileSelection: (key: string) => void;
clearSelection: () => void;
selectAll: () => void;
}
export const useExplorerStore = create<ExplorerState>((set, get) => ({
// Initial state
aliases: [],
aliasesLoading: false,
aliasesError: null,
currentAlias: null,
currentBucket: null,
currentPath: '',
files: [],
filesLoading: false,
filesError: null,
selectedFiles: [],
// Alias Actions
loadAliases: async () => {
set({ aliasesLoading: true, aliasesError: null });
try {
const response = await api.get<{ aliases: Alias[] }>('/aliases');
set({ aliases: response.data.aliases, aliasesLoading: false });
} catch (error: any) {
set({
aliasesError: error.response?.data?.message || 'Failed to load aliases',
aliasesLoading: false,
});
throw error;
}
},
addAlias: async (aliasName, endpoint, accessKey, secretKey) => {
const response = await api.post('/aliases', {
aliasName,
endpoint,
accessKey,
secretKey,
});
// Reload aliases after adding
await get().loadAliases();
return response.data;
},
updateAlias: async (aliasName, endpoint, accessKey, secretKey) => {
await api.put(`/aliases/${aliasName}`, {
endpoint,
accessKey,
secretKey,
});
await get().loadAliases();
},
removeAlias: async (aliasName) => {
await api.delete(`/aliases/${aliasName}`);
await get().loadAliases();
},
testConnection: async (aliasName) => {
const response = await api.post<{ status: string; error?: string }>(`/aliases/${aliasName}/test`);
return response.data;
},
// File Browser Actions
setCurrentLocation: (alias, bucket, path) => {
set({
currentAlias: alias,
currentBucket: bucket,
currentPath: path,
selectedFiles: [],
});
},
loadFiles: async (alias, bucket, prefix = '') => {
set({ filesLoading: true, filesError: null, selectedFiles: [] });
try {
const response = await api.get<{ objects: FileEntry[]; prefix: string; count: number }>(
`/browser/${alias}/${bucket}`,
{ params: { prefix } }
);
set({
files: response.data.objects,
currentPath: prefix,
filesLoading: false,
});
} catch (error: any) {
set({
filesError: error.response?.data?.message || 'Failed to load files',
filesLoading: false,
files: [],
});
throw error;
}
},
uploadFiles: async (alias, bucket, files, prefix = '') => {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
await api.post(`/browser/${alias}/${bucket}/upload`, formData, {
params: { prefix },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000, // 5 min for large uploads
});
// Reload files after upload
await get().loadFiles(alias, bucket, prefix);
},
deleteFile: async (alias, bucket, path, recursive = false) => {
await api.delete(`/browser/${alias}/${bucket}/${encodeURIComponent(path)}`, {
params: { recursive },
});
// Reload files after delete
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
renameFile: async (alias, bucket, source, destination) => {
await api.post(`/browser/${alias}/${bucket}/rename`, {
source,
destination,
});
// Reload files after rename
const state = get();
await get().loadFiles(alias, bucket, state.currentPath);
},
createFolder: async (alias, bucket, folderName, prefix = '') => {
await api.post(
`/browser/${alias}/${bucket}/mkdir`,
{ folderName },
{ params: { prefix } }
);
// Reload files after creating folder
await get().loadFiles(alias, bucket, prefix);
},
downloadFile: async (alias, bucket, path) => {
const response = await api.get(`/browser/${alias}/${bucket}/download/${encodeURIComponent(path)}`, {
responseType: 'blob',
timeout: 300000, // 5 min for large downloads
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
const fileName = path.split('/').pop() || 'download';
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// Selection Actions
toggleFileSelection: (key) => {
set((state) => {
const isSelected = state.selectedFiles.includes(key);
return {
selectedFiles: isSelected
? state.selectedFiles.filter((k) => k !== key)
: [...state.selectedFiles, key],
};
});
},
clearSelection: () => {
set({ selectedFiles: [] });
},
selectAll: () => {
set((state) => ({
selectedFiles: state.files.map((f) => f.key),
}));
},
}));
+128
View File
@@ -0,0 +1,128 @@
import { create } from 'zustand';
import api from '../services/api';
export interface OutputEntry {
id: string;
command: string;
output: string;
error: string;
isError: boolean;
timestamp: Date;
executionTime: number;
}
interface TerminalState {
commandHistory: string[];
outputHistory: OutputEntry[];
currentCommand: string;
historyIndex: number;
isExecuting: boolean;
// Actions
executeCommand: (command: string) => Promise<void>;
clearOutput: () => void;
navigateHistory: (direction: 'up' | 'down') => void;
setCurrentCommand: (command: string) => void;
}
export const useTerminalStore = create<TerminalState>((set, get) => ({
commandHistory: [],
outputHistory: [],
currentCommand: '',
historyIndex: -1,
isExecuting: false,
executeCommand: async (command: string) => {
if (!command.trim()) return;
const state = get();
// Add to command history (avoid duplicates at the end)
const newHistory = [...state.commandHistory];
if (newHistory[newHistory.length - 1] !== command) {
newHistory.push(command);
}
set({
isExecuting: true,
currentCommand: '',
commandHistory: newHistory,
historyIndex: -1,
});
try {
const response = await api.post<{
success: boolean;
output: string;
error: string;
exitCode: number;
executionTime: number;
}>('/terminal/execute', { command });
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: response.data.output,
error: response.data.error,
isError: !response.data.success,
timestamp: new Date(),
executionTime: response.data.executionTime,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
} catch (error: any) {
const entry: OutputEntry = {
id: Date.now().toString(),
command,
output: '',
error: error.response?.data?.message || error.message || 'Command execution failed',
isError: true,
timestamp: new Date(),
executionTime: 0,
};
set((state) => ({
outputHistory: [...state.outputHistory, entry],
isExecuting: false,
}));
}
},
clearOutput: () => {
set({ outputHistory: [] });
},
navigateHistory: (direction: 'up' | 'down') => {
const state = get();
const { commandHistory, historyIndex } = state;
if (commandHistory.length === 0) return;
let newIndex: number;
if (direction === 'up') {
newIndex = historyIndex === -1
? commandHistory.length - 1
: Math.max(0, historyIndex - 1);
} else {
newIndex = historyIndex === -1
? -1
: Math.min(commandHistory.length - 1, historyIndex + 1);
if (historyIndex === commandHistory.length - 1) {
newIndex = -1;
}
}
set({
historyIndex: newIndex,
currentCommand: newIndex === -1 ? '' : commandHistory[newIndex],
});
},
setCurrentCommand: (command: string) => {
set({ currentCommand: command, historyIndex: -1 });
},
}));