EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
MeshCentralFileBrowser.jsx
Go to the documentation of this file.
1/**
2 * MeshCentral File Browser Component
3 *
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.
7 *
8 * Features:
9 * - Browse remote file system
10 * - Download files
11 * - Upload files
12 * - Delete files/directories
13 * - Rename files/directories
14 * - Create directories
15 * - Navigate directory tree
16 */
17
18import { useState, useEffect, useRef } from 'react';
19import {
20 Box,
21 Typography,
22 IconButton,
23 List,
24 ListItem,
25 ListItemIcon,
26 ListItemText,
27 ListItemButton,
28 Breadcrumbs,
29 Link,
30 Paper,
31 CircularProgress,
32 Alert,
33 Tooltip,
34 Menu,
35 MenuItem,
36 Dialog,
37 DialogTitle,
38 DialogContent,
39 DialogActions,
40 Button,
41 TextField
42} from '@mui/material';
43import {
44 Folder as FolderIcon,
45 InsertDriveFile as FileIcon,
46 ArrowUpward as UpIcon,
47 Download as DownloadIcon,
48 Upload as UploadIcon,
49 Delete as DeleteIcon,
50 Edit as RenameIcon,
51 CreateNewFolder as NewFolderIcon,
52 Refresh as RefreshIcon,
53 MoreVert as MoreIcon
54} from '@mui/icons-material';
55
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);
64
65 const wsRef = useRef(null);
66 const requestIdRef = useRef(0);
67 const pendingRequestsRef = useRef(new Map());
68
69 useEffect(() => {
70 connectWebSocket();
71 return () => {
72 if (wsRef.current) {
73 wsRef.current.close();
74 }
75 };
76 }, [agentId, sessionToken]);
77
78 const connectWebSocket = () => {
79 setStatus('connecting');
80 const wsUrl = `wss://rmm-psa-backend-t9f7k.ondigitalocean.app/api/meshcentral/files?sessionToken=${sessionToken}&agentId=${agentId}`;
81
82 const ws = new WebSocket(wsUrl);
83 wsRef.current = ws;
84
85 ws.onopen = () => {
86 console.log('[FileBrowser] WebSocket connected');
87 setStatus('connected');
88 };
89
90 ws.onmessage = (event) => {
91 try {
92 const message = JSON.parse(event.data);
93 console.log('[FileBrowser] Received:', message);
94
95 if (message.type === 'connected') {
96 // Request root directory listing
97 listDirectory('');
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);
106 setStatus('error');
107 }
108 } catch (err) {
109 console.error('[FileBrowser] Failed to parse message:', err);
110 }
111 };
112
113 ws.onerror = (error) => {
114 console.error('[FileBrowser] WebSocket error:', error);
115 setStatus('error');
116 };
117
118 ws.onclose = () => {
119 console.log('[FileBrowser] WebSocket disconnected');
120 setStatus('disconnected');
121 };
122 };
123
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));
129 return reqid;
130 }
131 return null;
132 };
133
134 const listDirectory = (path) => {
135 setLoading(true);
136 sendMessage({
137 action: 'ls',
138 path: path
139 });
140 };
141
142 const handleDirectoryListing = (message) => {
143 setLoading(false);
144 if (message.dir) {
145 setCurrentPath(message.path || '');
146 setFiles(message.dir || []);
147 }
148 };
149
150 const navigateTo = (path) => {
151 listDirectory(path);
152 };
153
154 const navigateUp = () => {
155 const pathParts = currentPath.split('/').filter(p => p);
156 pathParts.pop();
157 const parentPath = pathParts.join('/');
158 navigateTo(parentPath);
159 };
160
161 const downloadFile = (file) => {
162 const filePath = currentPath ? `${currentPath}/${file.n}` : file.n;
163 sendMessage({
164 action: 'download',
165 path: filePath
166 });
167 };
168
169 const handleDownload = (message) => {
170 // MeshCentral will send file data as base64
171 if (message.data) {
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');
175 a.href = url;
176 a.download = message.name || 'download';
177 a.click();
178 window.URL.revokeObjectURL(url);
179 }
180 };
181
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;
187 sendMessage({
188 action: 'upload',
189 path: uploadPath,
190 data: base64
191 });
192 };
193 reader.readAsBinaryString(file);
194 };
195
196 const handleUploadDone = (message) => {
197 console.log('[FileBrowser] Upload complete');
198 listDirectory(currentPath); // Refresh directory
199 };
200
201 const deleteFile = (file) => {
202 const filePath = currentPath ? `${currentPath}/${file.n}` : file.n;
203 sendMessage({
204 action: 'rm',
205 path: filePath,
206 rec: file.t === 2 ? 1 : 0 // Recursive if directory
207 });
208 setTimeout(() => listDirectory(currentPath), 500);
209 };
210
211 const renameFile = (file, newName) => {
212 const oldPath = currentPath ? `${currentPath}/${file.n}` : file.n;
213 const newPath = currentPath ? `${currentPath}/${newName}` : newName;
214 sendMessage({
215 action: 'rename',
216 oldpath: oldPath,
217 newpath: newPath
218 });
219 setTimeout(() => listDirectory(currentPath), 500);
220 };
221
222 const createDirectory = (dirName) => {
223 const dirPath = currentPath ? `${currentPath}/${dirName}` : dirName;
224 sendMessage({
225 action: 'mkdir',
226 path: dirPath
227 });
228 setTimeout(() => listDirectory(currentPath), 500);
229 };
230
231 const handleContextMenu = (event, file) => {
232 event.preventDefault();
233 setSelectedFile(file);
234 setContextMenu({
235 mouseX: event.clientX - 2,
236 mouseY: event.clientY - 4
237 });
238 };
239
240 const handleCloseContextMenu = () => {
241 setContextMenu(null);
242 };
243
244 const openDialog = (type, data = null) => {
245 setDialog({ open: true, type, data });
246 handleCloseContextMenu();
247 };
248
249 const closeDialog = () => {
250 setDialog({ open: false, type: '', data: null });
251 };
252
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);
260 }
261 } else if (dialog.type === 'mkdir') {
262 const input = document.getElementById('mkdir-input');
263 if (input && input.value) {
264 createDirectory(input.value);
265 }
266 }
267 closeDialog();
268 };
269
270 const getPathParts = () => {
271 return currentPath.split('/').filter(p => p);
272 };
273
274 const formatFileSize = (bytes) => {
275 if (bytes === 0) return '0 B';
276 const k = 1024;
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];
280 };
281
282 const statusColors = {
283 connecting: 'info',
284 connected: 'success',
285 error: 'error',
286 disconnected: 'warning'
287 };
288
289 return (
290 <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
291 {/* Status Bar */}
292 <Alert severity={statusColors[status]} sx={{ mb: 2 }}>
293 Status: {status}
294 </Alert>
295
296 {/* Toolbar */}
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}>
300 <UpIcon />
301 </IconButton>
302 </Tooltip>
303 <Tooltip title="Refresh">
304 <IconButton onClick={() => listDirectory(currentPath)} disabled={loading}>
305 <RefreshIcon />
306 </IconButton>
307 </Tooltip>
308 <Tooltip title="Upload File">
309 <IconButton component="label" disabled={loading}>
310 <UploadIcon />
311 <input
312 type="file"
313 hidden
314 onChange={(e) => e.target.files[0] && uploadFile(e.target.files[0])}
315 />
316 </IconButton>
317 </Tooltip>
318 <Tooltip title="New Folder">
319 <IconButton onClick={() => openDialog('mkdir')} disabled={loading}>
320 <NewFolderIcon />
321 </IconButton>
322 </Tooltip>
323
324 {/* Breadcrumbs */}
325 <Box sx={{ flex: 1, ml: 2 }}>
326 <Breadcrumbs>
327 <Link
328 component="button"
329 variant="body2"
330 onClick={() => navigateTo('')}
331 underline="hover"
332 >
333 Root
334 </Link>
335 {getPathParts().map((part, index) => (
336 <Link
337 key={index}
338 component="button"
339 variant="body2"
340 onClick={() => {
341 const path = getPathParts().slice(0, index + 1).join('/');
342 navigateTo(path);
343 }}
344 underline="hover"
345 >
346 {part}
347 </Link>
348 ))}
349 </Breadcrumbs>
350 </Box>
351
352 {loading && <CircularProgress size={24} />}
353 </Paper>
354
355 {/* File List */}
356 <Paper sx={{ flex: 1, overflow: 'auto' }}>
357 <List>
358 {files.map((file, index) => (
359 <ListItem
360 key={index}
361 disablePadding
362 secondaryAction={
363 <IconButton
364 edge="end"
365 onClick={(e) => handleContextMenu(e, file)}
366 >
367 <MoreIcon />
368 </IconButton>
369 }
370 >
371 <ListItemButton
372 onContextMenu={(e) => handleContextMenu(e, file)}
373 onClick={() => {
374 if (file.t === 2) { // Directory
375 const newPath = currentPath ? `${currentPath}/${file.n}` : file.n;
376 navigateTo(newPath);
377 }
378 }}
379 >
380 <ListItemIcon>
381 {file.t === 2 ? <FolderIcon /> : <FileIcon />}
382 </ListItemIcon>
383 <ListItemText
384 primary={file.n}
385 secondary={file.t === 3 ? formatFileSize(file.s || 0) : 'Folder'}
386 />
387 </ListItemButton>
388 </ListItem>
389 ))}
390 </List>
391 </Paper>
392
393 {/* Context Menu */}
394 <Menu
395 open={contextMenu !== null}
396 onClose={handleCloseContextMenu}
397 anchorReference="anchorPosition"
398 anchorPosition={
399 contextMenu !== null
400 ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
401 : undefined
402 }
403 >
404 {selectedFile?.t === 3 && (
405 <MenuItem onClick={() => downloadFile(selectedFile)}>
406 <DownloadIcon sx={{ mr: 1 }} /> Download
407 </MenuItem>
408 )}
409 <MenuItem onClick={() => openDialog('rename', selectedFile)}>
410 <RenameIcon sx={{ mr: 1 }} /> Rename
411 </MenuItem>
412 <MenuItem onClick={() => openDialog('delete', selectedFile)}>
413 <DeleteIcon sx={{ mr: 1 }} /> Delete
414 </MenuItem>
415 </Menu>
416
417 {/* Dialogs */}
418 <Dialog open={dialog.open} onClose={closeDialog}>
419 <DialogTitle>
420 {dialog.type === 'delete' && 'Confirm Delete'}
421 {dialog.type === 'rename' && 'Rename'}
422 {dialog.type === 'mkdir' && 'Create Directory'}
423 </DialogTitle>
424 <DialogContent>
425 {dialog.type === 'delete' && (
426 <Typography>
427 Are you sure you want to delete "{selectedFile?.n}"?
428 </Typography>
429 )}
430 {dialog.type === 'rename' && (
431 <TextField
432 id="rename-input"
433 autoFocus
434 fullWidth
435 label="New name"
436 defaultValue={selectedFile?.n}
437 sx={{ mt: 2 }}
438 />
439 )}
440 {dialog.type === 'mkdir' && (
441 <TextField
442 id="mkdir-input"
443 autoFocus
444 fullWidth
445 label="Directory name"
446 sx={{ mt: 2 }}
447 />
448 )}
449 </DialogContent>
450 <DialogActions>
451 <Button onClick={closeDialog}>Cancel</Button>
452 <Button onClick={handleDialogAction} variant="contained">
453 {dialog.type === 'delete' ? 'Delete' : 'OK'}
454 </Button>
455 </DialogActions>
456 </Dialog>
457 </Box>
458 );
459}