1import React, { useState, useEffect, useRef } from 'react';
2import { apiFetch } from '../../lib/api';
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');
14 const [contextMenu, setContextMenu] = useState(null);
15 const [selectedFile, setSelectedFile] = useState(null);
18 const [showRenameDialog, setShowRenameDialog] = useState(false);
19 const [showMkdirDialog, setShowMkdirDialog] = useState(false);
20 const [showDeleteDialog, setShowDeleteDialog] = useState(false);
21 const [dialogInput, setDialogInput] = useState('');
23 // Fetch MeshCentral node ID
25 async function fetchNodeId() {
27 const response = await apiFetch(`/agent/${agentUuid || agentId}/meshcentral-node`);
29 throw new Error('Failed to fetch MeshCentral node ID');
31 const data = await response.json();
32 setNodeId(data.nodeId);
34 console.error('[Files] Error fetching node ID:', err);
35 setError(err.message);
40 if (agentUuid || agentId) {
43 }, [agentId, agentUuid]);
45 // Initialize WebSocket connection
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}`
56 console.log('[Files] WebSocket connected');
57 setStatus('connected');
58 // Load root directory
62 ws.onmessage = (event) => {
63 const data = JSON.parse(event.data);
65 if (data.action === 'ls') {
66 setFiles(data.files || []);
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');
74 a.download = data.name;
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);
86 ws.onerror = (err) => {
87 console.error('[Files] WebSocket error:', err);
89 setError('WebSocket connection error');
93 console.log('[Files] WebSocket disconnected');
94 setStatus('disconnected');
100 if (ws.readyState === WebSocket.OPEN) {
107 const listDirectory = (path) => {
108 if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
112 wsRef.current.send(JSON.stringify({
117 setCurrentPath(path);
120 // Navigate up one level
121 const navigateUp = () => {
122 const parts = currentPath.split('/').filter(Boolean);
124 const newPath = '/' + parts.join('/');
125 listDirectory(newPath || '/');
128 // Navigate into directory
129 const navigateInto = (item) => {
130 if (item.type === 'd') {
131 const newPath = currentPath === '/'
133 : `${currentPath}/${item.name}`;
134 listDirectory(newPath);
139 const downloadFile = (item) => {
140 if (!wsRef.current || item.type === 'd') return;
142 const filePath = currentPath === '/'
144 : `${currentPath}/${item.name}`;
146 wsRef.current.send(JSON.stringify({
154 const uploadFile = (event) => {
155 const file = event.target.files[0];
156 if (!file || !wsRef.current) return;
158 const reader = new FileReader();
159 reader.onload = (e) => {
160 const data = btoa(e.target.result);
161 const filePath = currentPath === '/'
163 : `${currentPath}/${file.name}`;
165 wsRef.current.send(JSON.stringify({
172 reader.readAsBinaryString(file);
175 // Delete file/folder
176 const deleteItem = () => {
177 if (!wsRef.current || !selectedFile) return;
179 const filePath = currentPath === '/'
180 ? `/${selectedFile.name}`
181 : `${currentPath}/${selectedFile.name}`;
183 wsRef.current.send(JSON.stringify({
189 setShowDeleteDialog(false);
190 setSelectedFile(null);
193 // Rename file/folder
194 const renameItem = () => {
195 if (!wsRef.current || !selectedFile || !dialogInput) return;
197 const oldPath = currentPath === '/'
198 ? `/${selectedFile.name}`
199 : `${currentPath}/${selectedFile.name}`;
200 const newPath = currentPath === '/'
202 : `${currentPath}/${dialogInput}`;
204 wsRef.current.send(JSON.stringify({
211 setShowRenameDialog(false);
213 setSelectedFile(null);
217 const createDirectory = () => {
218 if (!wsRef.current || !dialogInput) return;
220 const dirPath = currentPath === '/'
222 : `${currentPath}/${dialogInput}`;
224 wsRef.current.send(JSON.stringify({
230 setShowMkdirDialog(false);
234 // Context menu handler
235 const handleContextMenu = (event, file) => {
236 event.preventDefault();
241 setSelectedFile(file);
244 // Close context menu
246 const handleClick = () => setContextMenu(null);
247 document.addEventListener('click', handleClick);
248 return () => document.removeEventListener('click', handleClick);
252 const formatSize = (bytes) => {
253 if (!bytes) return '0 B';
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]}`;
261 const formatDate = (timestamp) => {
262 if (!timestamp) return 'N/A';
263 return new Date(timestamp * 1000).toLocaleString();
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
282 if (status === 'initializing') {
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>
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' }}>
303 backgroundColor: status === 'connected' ? '#4caf50' : status === 'error' ? '#f44336' : '#ff9800'
305 <span style={{ fontSize: '0.9rem', color: '#666', textTransform: 'capitalize' }}>
312 <div className="card" style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px', padding: '12px' }}>
316 disabled={currentPath === '/'}
317 title="Go up one level"
319 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>arrow_upward</span>
325 background: '#f5f5f5',
327 fontFamily: 'monospace',
335 onClick={() => setShowMkdirDialog(true)}
338 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>create_new_folder</span>
343 style={{ margin: 0, cursor: 'pointer' }}
346 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>upload_file</span>
349 style={{ display: 'none' }}
350 onChange={uploadFile}
356 onClick={() => listDirectory(currentPath)}
359 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>refresh</span>
364 <div className="card" style={{ padding: '0' }}>
366 <div style={{ padding: '2rem', textAlign: 'center' }}>
367 <div className="spinner" style={{ margin: '0 auto 1rem' }}></div>
368 <p>Loading files...</p>
370 ) : files.length === 0 ? (
371 <div style={{ padding: '2rem', textAlign: 'center', color: '#999' }}>
375 <div style={{ display: 'grid', gridTemplateColumns: '40px 1fr 120px 180px', fontSize: '14px' }}>
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>
383 {files.map((file, index) => (
384 <React.Fragment key={index}>
387 padding: '12px 16px',
388 borderBottom: '1px solid #eee',
392 onContextMenu={(e) => handleContextMenu(e, file)}
394 <span className="material-symbols-outlined" style={{
396 color: file.type === 'd' ? '#ffc107' : '#666'
398 {file.type === 'd' ? 'folder' : 'description'}
404 padding: '12px 16px',
405 borderBottom: '1px solid #eee',
406 cursor: file.type === 'd' ? 'pointer' : 'default',
408 textOverflow: 'ellipsis',
411 onDoubleClick={() => navigateInto(file)}
412 onContextMenu={(e) => handleContextMenu(e, file)}
418 style={{ padding: '12px 16px', borderBottom: '1px solid #eee', color: '#666' }}
419 onContextMenu={(e) => handleContextMenu(e, file)}
421 {file.type === 'd' ? '—' : formatSize(file.size)}
425 style={{ padding: '12px 16px', borderBottom: '1px solid #eee', color: '#666', fontSize: '13px' }}
426 onContextMenu={(e) => handleContextMenu(e, file)}
428 {formatDate(file.mtime)}
437 {contextMenu && selectedFile && (
444 border: '1px solid #ccc',
446 boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
451 {selectedFile.type !== 'd' && (
453 style={{ padding: '8px 16px', cursor: 'pointer', ':hover': { background: '#f5f5f5' } }}
455 downloadFile(selectedFile);
456 setContextMenu(null);
463 style={{ padding: '8px 16px', cursor: 'pointer' }}
465 setDialogInput(selectedFile.name);
466 setShowRenameDialog(true);
467 setContextMenu(null);
473 style={{ padding: '8px 16px', cursor: 'pointer', color: '#f44336' }}
475 setShowDeleteDialog(true);
476 setContextMenu(null);
484 {/* Rename Dialog */}
485 {showRenameDialog && (
492 background: 'rgba(0,0,0,0.5)',
494 alignItems: 'center',
495 justifyContent: 'center',
504 <h3 style={{ marginTop: 0 }}>Rename {selectedFile?.name}</h3>
508 onChange={(e) => setDialogInput(e.target.value)}
509 style={{ width: '100%', padding: '8px', marginBottom: '16px' }}
512 <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
513 <button className="btn-sm" onClick={() => setShowRenameDialog(false)}>
516 <button className="btn-sm" onClick={renameItem}>
524 {/* New Folder Dialog */}
525 {showMkdirDialog && (
532 background: 'rgba(0,0,0,0.5)',
534 alignItems: 'center',
535 justifyContent: 'center',
544 <h3 style={{ marginTop: 0 }}>New Folder</h3>
548 onChange={(e) => setDialogInput(e.target.value)}
549 placeholder="Folder name"
550 style={{ width: '100%', padding: '8px', marginBottom: '16px' }}
553 <div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
554 <button className="btn-sm" onClick={() => setShowMkdirDialog(false)}>
557 <button className="btn-sm" onClick={createDirectory}>
565 {/* Delete Confirmation Dialog */}
566 {showDeleteDialog && selectedFile && (
573 background: 'rgba(0,0,0,0.5)',
575 alignItems: 'center',
576 justifyContent: 'center',
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)}>
594 style={{ background: '#f44336', color: 'white' }}