EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
TabFiles.jsx
Go to the documentation of this file.
1import React, { useState, useEffect, useRef } from 'react';
2import { apiFetch } from '../../lib/api';
3
4export default function TabFiles({ agentId, agentUuid }) {
5 const wsRef = useRef(null);
6 const [currentPath, setCurrentPath] = useState('/');
7 const [files, setFiles] = useState([]);
8 const [loading, setLoading] = useState(true);
9 const [error, setError] = useState(null);
10 const [nodeId, setNodeId] = useState(null);
11 const [status, setStatus] = useState('initializing');
12
13 // Context menu state
14 const [contextMenu, setContextMenu] = useState(null);
15 const [selectedFile, setSelectedFile] = useState(null);
16
17 // Dialog state
18 const [showRenameDialog, setShowRenameDialog] = useState(false);
19 const [showMkdirDialog, setShowMkdirDialog] = useState(false);
20 const [showDeleteDialog, setShowDeleteDialog] = useState(false);
21 const [dialogInput, setDialogInput] = useState('');
22
23 // Fetch MeshCentral node ID
24 useEffect(() => {
25 async function fetchNodeId() {
26 try {
27 const response = await apiFetch(`/agent/${agentUuid || agentId}/meshcentral-node`);
28 if (!response.ok) {
29 throw new Error('Failed to fetch MeshCentral node ID');
30 }
31 const data = await response.json();
32 setNodeId(data.nodeId);
33 } catch (err) {
34 console.error('[Files] Error fetching node ID:', err);
35 setError(err.message);
36 setStatus('error');
37 }
38 }
39
40 if (agentUuid || agentId) {
41 fetchNodeId();
42 }
43 }, [agentId, agentUuid]);
44
45 // Initialize WebSocket connection
46 useEffect(() => {
47 if (!nodeId) return;
48
49 const token = localStorage.getItem('token');
50 const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
51 const ws = new WebSocket(
52 `${protocol}://${window.location.host}/api/meshcentral/files?nodeId=${nodeId}&token=${token}`
53 );
54
55 ws.onopen = () => {
56 console.log('[Files] WebSocket connected');
57 setStatus('connected');
58 // Load root directory
59 listDirectory('/');
60 };
61
62 ws.onmessage = (event) => {
63 const data = JSON.parse(event.data);
64
65 if (data.action === 'ls') {
66 setFiles(data.files || []);
67 setLoading(false);
68 } else if (data.action === 'download') {
69 // Create download link
70 const blob = new Blob([atob(data.data)], { type: 'application/octet-stream' });
71 const url = URL.createObjectURL(blob);
72 const a = document.createElement('a');
73 a.href = url;
74 a.download = data.name;
75 a.click();
76 URL.revokeObjectURL(url);
77 } else if (data.action === 'error') {
78 console.error('[Files] Error:', data.message);
79 setError(data.message);
80 } else if (data.action === 'success') {
81 // Refresh directory after successful operation
82 listDirectory(currentPath);
83 }
84 };
85
86 ws.onerror = (err) => {
87 console.error('[Files] WebSocket error:', err);
88 setStatus('error');
89 setError('WebSocket connection error');
90 };
91
92 ws.onclose = () => {
93 console.log('[Files] WebSocket disconnected');
94 setStatus('disconnected');
95 };
96
97 wsRef.current = ws;
98
99 return () => {
100 if (ws.readyState === WebSocket.OPEN) {
101 ws.close();
102 }
103 };
104 }, [nodeId]);
105
106 // List directory
107 const listDirectory = (path) => {
108 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
109
110 setLoading(true);
111 setError(null);
112 wsRef.current.send(JSON.stringify({
113 action: 'ls',
114 path: path,
115 reqid: Date.now()
116 }));
117 setCurrentPath(path);
118 };
119
120 // Navigate up one level
121 const navigateUp = () => {
122 const parts = currentPath.split('/').filter(Boolean);
123 parts.pop();
124 const newPath = '/' + parts.join('/');
125 listDirectory(newPath || '/');
126 };
127
128 // Navigate into directory
129 const navigateInto = (item) => {
130 if (item.type === 'd') {
131 const newPath = currentPath === '/'
132 ? `/${item.name}`
133 : `${currentPath}/${item.name}`;
134 listDirectory(newPath);
135 }
136 };
137
138 // Download file
139 const downloadFile = (item) => {
140 if (!wsRef.current || item.type === 'd') return;
141
142 const filePath = currentPath === '/'
143 ? `/${item.name}`
144 : `${currentPath}/${item.name}`;
145
146 wsRef.current.send(JSON.stringify({
147 action: 'download',
148 path: filePath,
149 reqid: Date.now()
150 }));
151 };
152
153 // Upload file
154 const uploadFile = (event) => {
155 const file = event.target.files[0];
156 if (!file || !wsRef.current) return;
157
158 const reader = new FileReader();
159 reader.onload = (e) => {
160 const data = btoa(e.target.result);
161 const filePath = currentPath === '/'
162 ? `/${file.name}`
163 : `${currentPath}/${file.name}`;
164
165 wsRef.current.send(JSON.stringify({
166 action: 'upload',
167 path: filePath,
168 data: data,
169 reqid: Date.now()
170 }));
171 };
172 reader.readAsBinaryString(file);
173 };
174
175 // Delete file/folder
176 const deleteItem = () => {
177 if (!wsRef.current || !selectedFile) return;
178
179 const filePath = currentPath === '/'
180 ? `/${selectedFile.name}`
181 : `${currentPath}/${selectedFile.name}`;
182
183 wsRef.current.send(JSON.stringify({
184 action: 'rm',
185 path: filePath,
186 reqid: Date.now()
187 }));
188
189 setShowDeleteDialog(false);
190 setSelectedFile(null);
191 };
192
193 // Rename file/folder
194 const renameItem = () => {
195 if (!wsRef.current || !selectedFile || !dialogInput) return;
196
197 const oldPath = currentPath === '/'
198 ? `/${selectedFile.name}`
199 : `${currentPath}/${selectedFile.name}`;
200 const newPath = currentPath === '/'
201 ? `/${dialogInput}`
202 : `${currentPath}/${dialogInput}`;
203
204 wsRef.current.send(JSON.stringify({
205 action: 'rename',
206 oldPath: oldPath,
207 newPath: newPath,
208 reqid: Date.now()
209 }));
210
211 setShowRenameDialog(false);
212 setDialogInput('');
213 setSelectedFile(null);
214 };
215
216 // Create directory
217 const createDirectory = () => {
218 if (!wsRef.current || !dialogInput) return;
219
220 const dirPath = currentPath === '/'
221 ? `/${dialogInput}`
222 : `${currentPath}/${dialogInput}`;
223
224 wsRef.current.send(JSON.stringify({
225 action: 'mkdir',
226 path: dirPath,
227 reqid: Date.now()
228 }));
229
230 setShowMkdirDialog(false);
231 setDialogInput('');
232 };
233
234 // Context menu handler
235 const handleContextMenu = (event, file) => {
236 event.preventDefault();
237 setContextMenu({
238 x: event.clientX,
239 y: event.clientY
240 });
241 setSelectedFile(file);
242 };
243
244 // Close context menu
245 useEffect(() => {
246 const handleClick = () => setContextMenu(null);
247 document.addEventListener('click', handleClick);
248 return () => document.removeEventListener('click', handleClick);
249 }, []);
250
251 // Format file size
252 const formatSize = (bytes) => {
253 if (!bytes) return '0 B';
254 const k = 1024;
255 const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
256 const i = Math.floor(Math.log(bytes) / Math.log(k));
257 return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
258 };
259
260 // Format date
261 const formatDate = (timestamp) => {
262 if (!timestamp) return 'N/A';
263 return new Date(timestamp * 1000).toLocaleString();
264 };
265
266 if (error) {
267 return (
268 <div className="tab-container">
269 <h2>File Manager</h2>
270 <div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
271 <div style={{ fontSize: '3rem', marginBottom: '1rem' }}>⚠️</div>
272 <h3>Connection Error</h3>
273 <p style={{ color: '#666' }}>{error}</p>
274 <p style={{ color: '#999', fontSize: '0.9rem', marginTop: '1rem' }}>
275 Make sure MeshCentral agent is installed and connected
276 </p>
277 </div>
278 </div>
279 );
280 }
281
282 if (status === 'initializing') {
283 return (
284 <div className="tab-container">
285 <h2>File Manager</h2>
286 <div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
287 <div className="spinner" style={{ margin: '0 auto 1rem' }}></div>
288 <p>Loading file manager...</p>
289 </div>
290 </div>
291 );
292 }
293
294 return (
295 <div className="tab-container">
296 <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
297 <h2 style={{ margin: 0 }}>File Manager</h2>
298 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
299 <div style={{
300 width: '8px',
301 height: '8px',
302 borderRadius: '50%',
303 backgroundColor: status === 'connected' ? '#4caf50' : status === 'error' ? '#f44336' : '#ff9800'
304 }} />
305 <span style={{ fontSize: '0.9rem', color: '#666', textTransform: 'capitalize' }}>
306 {status}
307 </span>
308 </div>
309 </div>
310
311 {/* Toolbar */}
312 <div className="card" style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', padding: '12px' }}>
313 <button
314 className="btn-sm"
315 onClick={navigateUp}
316 disabled={currentPath === '/'}
317 title="Go up one level"
318 >
319 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>arrow_upward</span>
320 </button>
321
322 <div style={{
323 flex: 1,
324 padding: '8px 12px',
325 background: '#f5f5f5',
326 borderRadius: '4px',
327 fontFamily: 'monospace',
328 fontSize: '14px'
329 }}>
330 {currentPath}
331 </div>
332
333 <button
334 className="btn-sm"
335 onClick={() => setShowMkdirDialog(true)}
336 title="New folder"
337 >
338 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>create_new_folder</span>
339 </button>
340
341 <label
342 className="btn-sm"
343 style={{ margin: 0, cursor: 'pointer' }}
344 title="Upload file"
345 >
346 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>upload_file</span>
347 <input
348 type="file"
349 style={{ display: 'none' }}
350 onChange={uploadFile}
351 />
352 </label>
353
354 <button
355 className="btn-sm"
356 onClick={() => listDirectory(currentPath)}
357 title="Refresh"
358 >
359 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>refresh</span>
360 </button>
361 </div>
362
363 {/* File list */}
364 <div className="card" style={{ padding: '0' }}>
365 {loading ? (
366 <div style={{ padding: '2rem', textAlign: 'center' }}>
367 <div className="spinner" style={{ margin: '0 auto 1rem' }}></div>
368 <p>Loading files...</p>
369 </div>
370 ) : files.length === 0 ? (
371 <div style={{ padding: '2rem', textAlign: 'center', color: '#999' }}>
372 Empty directory
373 </div>
374 ) : (
375 <div style={{ display: 'grid', gridTemplateColumns: '40px 1fr 120px 180px', fontSize: '14px' }}>
376 {/* Header */}
377 <div style={{ padding: '12px 16px', background: '#f5f5f5', fontWeight: 600 }}></div>
378 <div style={{ padding: '12px 16px', background: '#f5f5f5', fontWeight: 600 }}>Name</div>
379 <div style={{ padding: '12px 16px', background: '#f5f5f5', fontWeight: 600 }}>Size</div>
380 <div style={{ padding: '12px 16px', background: '#f5f5f5', fontWeight: 600 }}>Modified</div>
381
382 {/* Files */}
383 {files.map((file, index) => (
384 <React.Fragment key={index}>
385 <div
386 style={{
387 padding: '12px 16px',
388 borderBottom: '1px solid #eee',
389 display: 'flex',
390 alignItems: 'center'
391 }}
392 onContextMenu={(e) => handleContextMenu(e, file)}
393 >
394 <span className="material-symbols-outlined" style={{
395 fontSize: '24px',
396 color: file.type === 'd' ? '#ffc107' : '#666'
397 }}>
398 {file.type === 'd' ? 'folder' : 'description'}
399 </span>
400 </div>
401
402 <div
403 style={{
404 padding: '12px 16px',
405 borderBottom: '1px solid #eee',
406 cursor: file.type === 'd' ? 'pointer' : 'default',
407 overflow: 'hidden',
408 textOverflow: 'ellipsis',
409 whiteSpace: 'nowrap'
410 }}
411 onDoubleClick={() => navigateInto(file)}
412 onContextMenu={(e) => handleContextMenu(e, file)}
413 >
414 {file.name}
415 </div>
416
417 <div
418 style={{ padding: '12px 16px', borderBottom: '1px solid #eee', color: '#666' }}
419 onContextMenu={(e) => handleContextMenu(e, file)}
420 >
421 {file.type === 'd' ? '—' : formatSize(file.size)}
422 </div>
423
424 <div
425 style={{ padding: '12px 16px', borderBottom: '1px solid #eee', color: '#666', fontSize: '13px' }}
426 onContextMenu={(e) => handleContextMenu(e, file)}
427 >
428 {formatDate(file.mtime)}
429 </div>
430 </React.Fragment>
431 ))}
432 </div>
433 )}
434 </div>
435
436 {/* Context Menu */}
437 {contextMenu && selectedFile && (
438 <div
439 style={{
440 position: 'fixed',
441 top: contextMenu.y,
442 left: contextMenu.x,
443 background: 'white',
444 border: '1px solid #ccc',
445 borderRadius: '4px',
446 boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
447 zIndex: 1000,
448 minWidth: '150px'
449 }}
450 >
451 {selectedFile.type !== 'd' && (
452 <div
453 style={{ padding: '8px 16px', cursor: 'pointer', ':hover': { background: '#f5f5f5' } }}
454 onClick={() => {
455 downloadFile(selectedFile);
456 setContextMenu(null);
457 }}
458 >
459 Download
460 </div>
461 )}
462 <div
463 style={{ padding: '8px 16px', cursor: 'pointer' }}
464 onClick={() => {
465 setDialogInput(selectedFile.name);
466 setShowRenameDialog(true);
467 setContextMenu(null);
468 }}
469 >
470 Rename
471 </div>
472 <div
473 style={{ padding: '8px 16px', cursor: 'pointer', color: '#f44336' }}
474 onClick={() => {
475 setShowDeleteDialog(true);
476 setContextMenu(null);
477 }}
478 >
479 Delete
480 </div>
481 </div>
482 )}
483
484 {/* Rename Dialog */}
485 {showRenameDialog && (
486 <div style={{
487 position: 'fixed',
488 top: 0,
489 left: 0,
490 right: 0,
491 bottom: 0,
492 background: 'rgba(0,0,0,0.5)',
493 display: 'flex',
494 alignItems: 'center',
495 justifyContent: 'center',
496 zIndex: 1000
497 }}>
498 <div style={{
499 background: 'white',
500 padding: '24px',
501 borderRadius: '8px',
502 minWidth: '400px'
503 }}>
504 <h3 style={{ marginTop: 0 }}>Rename {selectedFile?.name}</h3>
505 <input
506 type="text"
507 value={dialogInput}
508 onChange={(e) => setDialogInput(e.target.value)}
509 style={{ width: '100%', padding: '8px', marginBottom: '16px' }}
510 autoFocus
511 />
512 <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
513 <button className="btn-sm" onClick={() => setShowRenameDialog(false)}>
514 Cancel
515 </button>
516 <button className="btn-sm" onClick={renameItem}>
517 Rename
518 </button>
519 </div>
520 </div>
521 </div>
522 )}
523
524 {/* New Folder Dialog */}
525 {showMkdirDialog && (
526 <div style={{
527 position: 'fixed',
528 top: 0,
529 left: 0,
530 right: 0,
531 bottom: 0,
532 background: 'rgba(0,0,0,0.5)',
533 display: 'flex',
534 alignItems: 'center',
535 justifyContent: 'center',
536 zIndex: 1000
537 }}>
538 <div style={{
539 background: 'white',
540 padding: '24px',
541 borderRadius: '8px',
542 minWidth: '400px'
543 }}>
544 <h3 style={{ marginTop: 0 }}>New Folder</h3>
545 <input
546 type="text"
547 value={dialogInput}
548 onChange={(e) => setDialogInput(e.target.value)}
549 placeholder="Folder name"
550 style={{ width: '100%', padding: '8px', marginBottom: '16px' }}
551 autoFocus
552 />
553 <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
554 <button className="btn-sm" onClick={() => setShowMkdirDialog(false)}>
555 Cancel
556 </button>
557 <button className="btn-sm" onClick={createDirectory}>
558 Create
559 </button>
560 </div>
561 </div>
562 </div>
563 )}
564
565 {/* Delete Confirmation Dialog */}
566 {showDeleteDialog && selectedFile && (
567 <div style={{
568 position: 'fixed',
569 top: 0,
570 left: 0,
571 right: 0,
572 bottom: 0,
573 background: 'rgba(0,0,0,0.5)',
574 display: 'flex',
575 alignItems: 'center',
576 justifyContent: 'center',
577 zIndex: 1000
578 }}>
579 <div style={{
580 background: 'white',
581 padding: '24px',
582 borderRadius: '8px',
583 minWidth: '400px'
584 }}>
585 <h3 style={{ marginTop: 0 }}>Confirm Delete</h3>
586 <p>Are you sure you want to delete <strong>{selectedFile.name}</strong>?</p>
587 <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
588 <button className="btn-sm" onClick={() => setShowDeleteDialog(false)}>
589 Cancel
590 </button>
591 <button
592 className="btn-sm"
593 onClick={deleteItem}
594 style={{ background: '#f44336', color: 'white' }}
595 >
596 Delete
597 </button>
598 </div>
599 </div>
600 </div>
601 )}
602 </div>
603 );
604}