import React, { useState, useEffect } from 'react'; import { Box, Container, Typography, Paper, Button, Grid, Card, CardContent, Chip, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, FormControl, InputLabel, Select, MenuItem, Alert, LinearProgress, Snackbar, Divider, FormControlLabel, Switch, Tooltip, CircularProgress, Fade, Stack, Avatar, Badge, useTheme, alpha, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, } from '@mui/material'; import { Add as AddIcon, CloudSync as CloudSyncIcon, Error as ErrorIcon, CheckCircle as CheckCircleIcon, Edit as EditIcon, Delete as DeleteIcon, PlayArrow as PlayArrowIcon, Storage as StorageIcon, Cloud as CloudIcon, Speed as SpeedIcon, Timeline as TimelineIcon, TrendingUp as TrendingUpIcon, Security as SecurityIcon, AutoFixHigh as AutoFixHighIcon, Sync as SyncIcon, MoreVert as MoreVertIcon, Menu as MenuIcon, Folder as FolderIcon, Assessment as AssessmentIcon, Extension as ExtensionIcon, Storage as ServerIcon, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import api from '../services/api'; import { formatDistanceToNow } from 'date-fns'; interface Source { id: string; name: string; source_type: 'webdav' | 'local_folder' | 's3'; enabled: boolean; config: any; status: 'idle' | 'syncing' | 'error'; last_sync_at: string | null; last_error: string | null; last_error_at: string | null; total_files_synced: number; total_files_pending: number; total_size_bytes: number; created_at: string; updated_at: string; } interface SnackbarState { open: boolean; message: string; severity: 'success' | 'error' | 'warning' | 'info'; } const SourcesPage: React.FC = () => { const theme = useTheme(); const navigate = useNavigate(); const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); const [editingSource, setEditingSource] = useState(null); const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'info', }); // Form state const [formData, setFormData] = useState({ name: '', source_type: 'webdav' as 'webdav' | 'local_folder' | 's3', enabled: true, server_url: '', username: '', password: '', watch_folders: ['/Documents'], file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'], auto_sync: false, sync_interval_minutes: 60, server_type: 'generic' as 'nextcloud' | 'owncloud' | 'generic', }); // Additional state for enhanced features const [newFolder, setNewFolder] = useState(''); const [newExtension, setNewExtension] = useState(''); const [crawlEstimate, setCrawlEstimate] = useState(null); const [estimatingCrawl, setEstimatingCrawl] = useState(false); const [testingConnection, setTestingConnection] = useState(false); const [syncingSource, setSyncingSource] = useState(null); useEffect(() => { loadSources(); }, []); const loadSources = async () => { try { const response = await api.get('/sources'); setSources(response.data); } catch (error) { console.error('Failed to load sources:', error); showSnackbar('Failed to load sources', 'error'); } finally { setLoading(false); } }; const showSnackbar = (message: string, severity: SnackbarState['severity']) => { setSnackbar({ open: true, message, severity }); }; const handleCreateSource = () => { setEditingSource(null); setFormData({ name: '', source_type: 'webdav', enabled: true, server_url: '', username: '', password: '', watch_folders: ['/Documents'], file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'], auto_sync: false, sync_interval_minutes: 60, server_type: 'generic', }); setCrawlEstimate(null); setNewFolder(''); setNewExtension(''); setDialogOpen(true); }; const handleEditSource = (source: Source) => { setEditingSource(source); const config = source.config; setFormData({ name: source.name, source_type: source.source_type, enabled: source.enabled, server_url: config.server_url || '', username: config.username || '', password: config.password || '', watch_folders: config.watch_folders || ['/Documents'], file_extensions: config.file_extensions || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'], auto_sync: config.auto_sync || false, sync_interval_minutes: config.sync_interval_minutes || 60, server_type: config.server_type || 'generic', }); setCrawlEstimate(null); setNewFolder(''); setNewExtension(''); setDialogOpen(true); }; const handleSaveSource = async () => { try { const config = { server_url: formData.server_url, username: formData.username, password: formData.password, watch_folders: formData.watch_folders, file_extensions: formData.file_extensions, auto_sync: formData.auto_sync, sync_interval_minutes: formData.sync_interval_minutes, server_type: formData.server_type, }; if (editingSource) { await api.put(`/sources/${editingSource.id}`, { name: formData.name, enabled: formData.enabled, config, }); showSnackbar('Source updated successfully', 'success'); } else { await api.post('/sources', { name: formData.name, source_type: formData.source_type, enabled: formData.enabled, config, }); showSnackbar('Source created successfully', 'success'); } setDialogOpen(false); loadSources(); } catch (error) { console.error('Failed to save source:', error); showSnackbar('Failed to save source', 'error'); } }; const handleDeleteSource = async (source: Source) => { if (!confirm(`Are you sure you want to delete "${source.name}"?`)) { return; } try { await api.delete(`/sources/${source.id}`); showSnackbar('Source deleted successfully', 'success'); loadSources(); } catch (error) { console.error('Failed to delete source:', error); showSnackbar('Failed to delete source', 'error'); } }; const handleTestConnection = async () => { setTestingConnection(true); try { const response = await api.post('/webdav/test-connection', { server_url: formData.server_url, username: formData.username, password: formData.password, server_type: formData.server_type, }); if (response.data.success) { showSnackbar('Connection successful!', 'success'); } else { showSnackbar(response.data.message || 'Connection failed', 'error'); } } catch (error) { console.error('Failed to test connection:', error); showSnackbar('Failed to test connection', 'error'); } finally { setTestingConnection(false); } }; const handleTriggerSync = async (sourceId: string) => { setSyncingSource(sourceId); try { await api.post(`/sources/${sourceId}/sync`); showSnackbar('Sync started successfully', 'success'); setTimeout(loadSources, 1000); } catch (error: any) { console.error('Failed to trigger sync:', error); if (error.response?.status === 409) { showSnackbar('Source is already syncing', 'warning'); } else { showSnackbar('Failed to start sync', 'error'); } } finally { setSyncingSource(null); } }; // Utility functions for folder management const addFolder = () => { if (newFolder && !formData.watch_folders.includes(newFolder)) { setFormData({ ...formData, watch_folders: [...formData.watch_folders, newFolder] }); setNewFolder(''); } }; const removeFolder = (folderToRemove: string) => { setFormData({ ...formData, watch_folders: formData.watch_folders.filter(folder => folder !== folderToRemove) }); }; // Utility functions for file extension management const addFileExtension = () => { if (newExtension && !formData.file_extensions.includes(newExtension)) { setFormData({ ...formData, file_extensions: [...formData.file_extensions, newExtension] }); setNewExtension(''); } }; const removeFileExtension = (extensionToRemove: string) => { setFormData({ ...formData, file_extensions: formData.file_extensions.filter(ext => ext !== extensionToRemove) }); }; // Crawl estimation function const estimateCrawl = async () => { setEstimatingCrawl(true); try { let response; if (editingSource) { // Use the source-specific endpoint for existing sources response = await api.post(`/sources/${editingSource.id}/estimate`); } else { // Use the general endpoint with provided config for new sources response = await api.post('/sources/estimate', { server_url: formData.server_url, username: formData.username, password: formData.password, watch_folders: formData.watch_folders, file_extensions: formData.file_extensions, auto_sync: formData.auto_sync, sync_interval_minutes: formData.sync_interval_minutes, server_type: formData.server_type, }); } setCrawlEstimate(response.data); showSnackbar('Crawl estimation completed', 'success'); } catch (error) { console.error('Failed to estimate crawl:', error); showSnackbar('Failed to estimate crawl', 'error'); } finally { setEstimatingCrawl(false); } }; const getSourceIcon = (sourceType: string) => { switch (sourceType) { case 'webdav': return ; case 's3': return ; case 'local_folder': return ; default: return ; } }; const getStatusIcon = (source: Source) => { if (source.status === 'syncing') { return ; } else if (source.status === 'error') { return ; } else { return ; } }; const getStatusColor = (status: string) => { switch (status) { case 'syncing': return theme.palette.info.main; case 'error': return theme.palette.error.main; default: return theme.palette.success.main; } }; const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; const StatCard = ({ icon, label, value, color = 'primary' }: { icon: React.ReactNode; label: string; value: string | number; color?: 'primary' | 'success' | 'warning' | 'error' }) => ( {icon} {typeof value === 'number' ? value.toLocaleString() : value} {label} ); const renderSourceCard = (source: Source) => ( {/* Header */} {getSourceIcon(source.source_type)} {source.name} {!source.enabled && ( )} {/* Action Buttons */} handleTriggerSync(source.id)} disabled={source.status === 'syncing' || !source.enabled} sx={{ bgcolor: alpha(theme.palette.primary.main, 0.1), '&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.2) }, }} > {syncingSource === source.id ? ( ) : ( )} handleEditSource(source)} sx={{ bgcolor: alpha(theme.palette.grey[500], 0.1), '&:hover': { bgcolor: alpha(theme.palette.grey[500], 0.2) }, }} > handleDeleteSource(source)} sx={{ bgcolor: alpha(theme.palette.error.main, 0.1), '&:hover': { bgcolor: alpha(theme.palette.error.main, 0.2) }, color: theme.palette.error.main, }} > {/* Stats Grid */} } label="Files Synced" value={source.total_files_synced} color="success" /> } label="Files Pending" value={source.total_files_pending} color="warning" /> } label="Total Size" value={formatBytes(source.total_size_bytes)} color="primary" /> } label="Last Sync" value={source.last_sync_at ? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true }) : 'Never'} color="primary" /> {/* Error Alert */} {source.last_error && ( {source.last_error} {source.last_error_at && ( {formatDistanceToNow(new Date(source.last_error_at), { addSuffix: true })} )} )} ); return ( {/* Header */} Document Sources Connect and manage your document sources with intelligent syncing {/* Content */} {loading ? ( ) : sources.length === 0 ? ( No Sources Configured Connect your first document source to start automatically syncing and processing your files with AI-powered OCR. ) : ( {sources.map(renderSourceCard)} )} {/* Create/Edit Dialog - Enhanced */} setDialogOpen(false)} maxWidth="md" fullWidth PaperProps={{ sx: { borderRadius: 4, background: theme.palette.background.paper, } }} > {editingSource ? : } {editingSource ? 'Edit Source' : 'Create New Source'} {editingSource ? 'Update your source configuration' : 'Connect a new document source'} setFormData({ ...formData, name: e.target.value })} placeholder="My Document Server" sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2, } }} /> {!editingSource && ( Source Type )} {formData.source_type === 'webdav' && ( WebDAV Configuration setFormData({ ...formData, server_url: e.target.value })} placeholder="https://nextcloud.example.com/remote.php/dav/files/username/" sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> setFormData({ ...formData, username: e.target.value })} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> setFormData({ ...formData, password: e.target.value })} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> Server Type setFormData({ ...formData, auto_sync: e.target.checked })} /> } label={ Enable Automatic Sync Automatically sync files on a schedule } /> {formData.auto_sync && ( setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })} inputProps={{ min: 15, max: 1440 }} helperText="How often to check for new files (15 min - 24 hours)" sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> )} {/* Folder Management */} Folders to Monitor Specify which folders to scan for files. Use absolute paths starting with "/". setNewFolder(e.target.value)} placeholder="/Documents" sx={{ flexGrow: 1, '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> {formData.watch_folders.map((folder, index) => ( removeFolder(folder)} sx={{ mr: 1, mb: 1, borderRadius: 2, bgcolor: alpha(theme.palette.secondary.main, 0.1), color: theme.palette.secondary.main, }} /> ))} {/* File Extensions */} File Extensions File types to sync and process with OCR. setNewExtension(e.target.value)} placeholder="docx" sx={{ flexGrow: 1, '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> {formData.file_extensions.map((extension, index) => ( removeFileExtension(extension)} sx={{ mr: 1, mb: 1, borderRadius: 2, bgcolor: alpha(theme.palette.warning.main, 0.1), color: theme.palette.warning.main, }} /> ))} {/* Crawl Estimation */} {editingSource && formData.server_url && formData.username && formData.watch_folders.length > 0 && ( <> Crawl Estimation Estimate how many files will be processed and how long it will take. {estimatingCrawl && ( Analyzing folders and counting files... )} {crawlEstimate && ( Estimation Results {crawlEstimate.total_files?.toLocaleString() || '0'} Total Files {crawlEstimate.total_supported_files?.toLocaleString() || '0'} Supported Files {crawlEstimate.total_estimated_time_hours?.toFixed(1) || '0'}h Estimated Time {crawlEstimate.total_size_mb ? (crawlEstimate.total_size_mb / 1024).toFixed(1) : '0'}GB Total Size {crawlEstimate.folders && crawlEstimate.folders.length > 0 && ( Folder Total Files Supported Est. Time Size (MB) {crawlEstimate.folders.map((folder: any) => ( {folder.path} {folder.total_files?.toLocaleString() || '0'} {folder.supported_files?.toLocaleString() || '0'} {folder.estimated_time_hours?.toFixed(1) || '0'}h {folder.total_size_mb?.toFixed(1) || '0'} ))}
)}
)} )}
)} setFormData({ ...formData, enabled: e.target.checked })} /> } label={ Source Enabled Enable this source for syncing } />
{editingSource && formData.source_type === 'webdav' && ( )}
{/* Snackbar */} setSnackbar({ ...snackbar, open: false })} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity} sx={{ width: '100%', borderRadius: 3, }} > {snackbar.message} {/* Custom CSS for animations */}
); }; export default SourcesPage;