2 * MeshCentral File Browser Component
4 * Direct WebSocket integration with MeshCentral for file operations.
5 * Provides a custom UI matching the dashboard design while using
6 * MeshCentral's file system API.
9 * - Browse remote file system
12 * - Delete files/directories
13 * - Rename files/directories
14 * - Create directories
15 * - Navigate directory tree
18import { useState, useEffect, useRef } from 'react';
42} from '@mui/material';
45 InsertDriveFile as FileIcon,
46 ArrowUpward as UpIcon,
47 Download as DownloadIcon,
51 CreateNewFolder as NewFolderIcon,
52 Refresh as RefreshIcon,
54} from '@mui/icons-material';
56export default function MeshCentralFileBrowser({ agentId, sessionToken }) {
57 const [status, setStatus] = useState('connecting'); // connecting, connected, error, disconnected
58 const [currentPath, setCurrentPath] = useState('');
59 const [files, setFiles] = useState([]);
60 const [selectedFile, setSelectedFile] = useState(null);
61 const [contextMenu, setContextMenu] = useState(null);
62 const [dialog, setDialog] = useState({ open: false, type: '', data: null });
63 const [loading, setLoading] = useState(false);
65 const wsRef = useRef(null);
66 const requestIdRef = useRef(0);
67 const pendingRequestsRef = useRef(new Map());
73 wsRef.current.close();
76 }, [agentId, sessionToken]);
78 const connectWebSocket = () => {
79 setStatus('connecting');
80 const wsUrl = `wss://rmm-psa-backend-t9f7k.ondigitalocean.app/api/meshcentral/files?sessionToken=${sessionToken}&agentId=${agentId}`;
82 const ws = new WebSocket(wsUrl);
86 console.log('[FileBrowser] WebSocket connected');
87 setStatus('connected');
90 ws.onmessage = (event) => {
92 const message = JSON.parse(event.data);
93 console.log('[FileBrowser] Received:', message);
95 if (message.type === 'connected') {
96 // Request root directory listing
98 } else if (message.action === 'ls') {
99 handleDirectoryListing(message);
100 } else if (message.action === 'download') {
101 handleDownload(message);
102 } else if (message.action === 'uploaddone') {
103 handleUploadDone(message);
104 } else if (message.type === 'error') {
105 console.error('[FileBrowser] Error:', message.message);
109 console.error('[FileBrowser] Failed to parse message:', err);
113 ws.onerror = (error) => {
114 console.error('[FileBrowser] WebSocket error:', error);
119 console.log('[FileBrowser] WebSocket disconnected');
120 setStatus('disconnected');
124 const sendMessage = (message) => {
125 if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
126 const reqid = ++requestIdRef.current;
127 const msgWithId = { ...message, reqid };
128 wsRef.current.send(JSON.stringify(msgWithId));
134 const listDirectory = (path) => {
142 const handleDirectoryListing = (message) => {
145 setCurrentPath(message.path || '');
146 setFiles(message.dir || []);
150 const navigateTo = (path) => {
154 const navigateUp = () => {
155 const pathParts = currentPath.split('/').filter(p => p);
157 const parentPath = pathParts.join('/');
158 navigateTo(parentPath);
161 const downloadFile = (file) => {
162 const filePath = currentPath ? `${currentPath}/${file.n}` : file.n;
169 const handleDownload = (message) => {
170 // MeshCentral will send file data as base64
172 const blob = new Blob([atob(message.data)], { type: 'application/octet-stream' });
173 const url = window.URL.createObjectURL(blob);
174 const a = document.createElement('a');
176 a.download = message.name || 'download';
178 window.URL.revokeObjectURL(url);
182 const uploadFile = (file) => {
183 const reader = new FileReader();
184 reader.onload = (e) => {
185 const base64 = btoa(e.target.result);
186 const uploadPath = currentPath ? `${currentPath}/${file.name}` : file.name;
193 reader.readAsBinaryString(file);
196 const handleUploadDone = (message) => {
197 console.log('[FileBrowser] Upload complete');
198 listDirectory(currentPath); // Refresh directory
201 const deleteFile = (file) => {
202 const filePath = currentPath ? `${currentPath}/${file.n}` : file.n;
206 rec: file.t === 2 ? 1 : 0 // Recursive if directory
208 setTimeout(() => listDirectory(currentPath), 500);
211 const renameFile = (file, newName) => {
212 const oldPath = currentPath ? `${currentPath}/${file.n}` : file.n;
213 const newPath = currentPath ? `${currentPath}/${newName}` : newName;
219 setTimeout(() => listDirectory(currentPath), 500);
222 const createDirectory = (dirName) => {
223 const dirPath = currentPath ? `${currentPath}/${dirName}` : dirName;
228 setTimeout(() => listDirectory(currentPath), 500);
231 const handleContextMenu = (event, file) => {
232 event.preventDefault();
233 setSelectedFile(file);
235 mouseX: event.clientX - 2,
236 mouseY: event.clientY - 4
240 const handleCloseContextMenu = () => {
241 setContextMenu(null);
244 const openDialog = (type, data = null) => {
245 setDialog({ open: true, type, data });
246 handleCloseContextMenu();
249 const closeDialog = () => {
250 setDialog({ open: false, type: '', data: null });
253 const handleDialogAction = () => {
254 if (dialog.type === 'delete' && selectedFile) {
255 deleteFile(selectedFile);
256 } else if (dialog.type === 'rename' && selectedFile) {
257 const input = document.getElementById('rename-input');
258 if (input && input.value) {
259 renameFile(selectedFile, input.value);
261 } else if (dialog.type === 'mkdir') {
262 const input = document.getElementById('mkdir-input');
263 if (input && input.value) {
264 createDirectory(input.value);
270 const getPathParts = () => {
271 return currentPath.split('/').filter(p => p);
274 const formatFileSize = (bytes) => {
275 if (bytes === 0) return '0 B';
277 const sizes = ['B', 'KB', 'MB', 'GB'];
278 const i = Math.floor(Math.log(bytes) / Math.log(k));
279 return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
282 const statusColors = {
284 connected: 'success',
286 disconnected: 'warning'
290 <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
292 <Alert severity={statusColors[status]} sx={{ mb: 2 }}>
297 <Paper sx={{ p: 1, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
298 <Tooltip title="Go Up">
299 <IconButton onClick={navigateUp} disabled={!currentPath || loading}>
303 <Tooltip title="Refresh">
304 <IconButton onClick={() => listDirectory(currentPath)} disabled={loading}>
308 <Tooltip title="Upload File">
309 <IconButton component="label" disabled={loading}>
314 onChange={(e) => e.target.files[0] && uploadFile(e.target.files[0])}
318 <Tooltip title="New Folder">
319 <IconButton onClick={() => openDialog('mkdir')} disabled={loading}>
325 <Box sx={{ flex: 1, ml: 2 }}>
330 onClick={() => navigateTo('')}
335 {getPathParts().map((part, index) => (
341 const path = getPathParts().slice(0, index + 1).join('/');
352 {loading && <CircularProgress size={24} />}
356 <Paper sx={{ flex: 1, overflow: 'auto' }}>
358 {files.map((file, index) => (
365 onClick={(e) => handleContextMenu(e, file)}
372 onContextMenu={(e) => handleContextMenu(e, file)}
374 if (file.t === 2) { // Directory
375 const newPath = currentPath ? `${currentPath}/${file.n}` : file.n;
381 {file.t === 2 ? <FolderIcon /> : <FileIcon />}
385 secondary={file.t === 3 ? formatFileSize(file.s || 0) : 'Folder'}
395 open={contextMenu !== null}
396 onClose={handleCloseContextMenu}
397 anchorReference="anchorPosition"
400 ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
404 {selectedFile?.t === 3 && (
405 <MenuItem onClick={() => downloadFile(selectedFile)}>
406 <DownloadIcon sx={{ mr: 1 }} /> Download
409 <MenuItem onClick={() => openDialog('rename', selectedFile)}>
410 <RenameIcon sx={{ mr: 1 }} /> Rename
412 <MenuItem onClick={() => openDialog('delete', selectedFile)}>
413 <DeleteIcon sx={{ mr: 1 }} /> Delete
418 <Dialog open={dialog.open} onClose={closeDialog}>
420 {dialog.type === 'delete' && 'Confirm Delete'}
421 {dialog.type === 'rename' && 'Rename'}
422 {dialog.type === 'mkdir' && 'Create Directory'}
425 {dialog.type === 'delete' && (
427 Are you sure you want to delete "{selectedFile?.n}"?
430 {dialog.type === 'rename' && (
436 defaultValue={selectedFile?.n}
440 {dialog.type === 'mkdir' && (
445 label="Directory name"
451 <Button onClick={closeDialog}>Cancel</Button>
452 <Button onClick={handleDialogAction} variant="contained">
453 {dialog.type === 'delete' ? 'Delete' : 'OK'}