Readur/frontend/src/pages/SourcesPage.tsx

2576 lines
95 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Paper,
Button,
Card,
CardContent,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
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 Grid from '@mui/material/GridLegacy';
import {
Add as AddIcon,
CloudSync as CloudSyncIcon,
Error as ErrorIcon,
CheckCircle as CheckCircleIcon,
Edit as EditIcon,
Delete as DeleteIcon,
PlayArrow as PlayArrowIcon,
Stop as StopIcon,
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,
Speed as QuickSyncIcon,
ManageSearch as DeepScanIcon,
Folder as FolderIcon,
Assessment as AssessmentIcon,
Extension as ExtensionIcon,
Storage as ServerIcon,
Pause as PauseIcon,
PlayArrow as ResumeIcon,
TextSnippet as DocumentIcon,
Visibility as OcrIcon,
Block as BlockIcon,
HealthAndSafety as HealthIcon,
Warning as WarningIcon,
Error as CriticalIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import api, { queueService, sourcesService, ErrorHelper, ErrorCodes } from '../services/api';
import { formatDistanceToNow } from 'date-fns';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from 'react-i18next';
import SyncProgressDisplay from '../components/SyncProgress';
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;
total_documents: number;
total_documents_ocr: number;
created_at: string;
updated_at: string;
// Validation fields
validation_status?: string | null;
last_validation_at?: string | null;
validation_score?: number | null;
validation_issues?: string | null;
}
interface SnackbarState {
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
}
const SourcesPage: React.FC = () => {
const theme = useTheme();
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
const [sources, setSources] = useState<Source[]>([]);
const [loading, setLoading] = useState(true);
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: string }>({ is_paused: false, status: 'running' });
const [ocrLoading, setOcrLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<Source | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sourceToDelete, setSourceToDelete] = useState<Source | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: '',
severity: 'info',
});
// Form state
const [formData, setFormData] = useState({
name: '',
source_type: 'webdav' as 'webdav' | 'local_folder' | 's3',
enabled: true,
// WebDAV fields
server_url: '',
username: '',
password: '',
server_type: 'generic' as 'nextcloud' | 'owncloud' | 'generic',
// Local Folder fields
recursive: true,
follow_symlinks: false,
// S3 fields
bucket_name: '',
region: 'us-east-1',
access_key_id: '',
secret_access_key: '',
endpoint_url: '',
prefix: '',
// Common fields
watch_folders: ['/Documents'],
file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
auto_sync: false,
sync_interval_minutes: 60,
});
// Additional state for enhanced features
const [newFolder, setNewFolder] = useState('');
const [newExtension, setNewExtension] = useState('');
const [crawlEstimate, setCrawlEstimate] = useState<any>(null);
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [syncingSource, setSyncingSource] = useState<string | null>(null);
const [stoppingSync, setStoppingSync] = useState<string | null>(null);
const [validating, setValidating] = useState<string | null>(null);
const [autoRefreshing, setAutoRefreshing] = useState(false);
// Sync modal state
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [sourceToSync, setSourceToSync] = useState<Source | null>(null);
const [deepScanning, setDeepScanning] = useState(false);
useEffect(() => {
loadSources();
if (user?.role === 'Admin') {
loadOcrStatus();
}
}, [user]);
// Auto-refresh sources when any source is syncing
useEffect(() => {
const activeSyncingSources = sources.filter(source => source.status === 'syncing');
if (activeSyncingSources.length > 0) {
setAutoRefreshing(true);
const interval = setInterval(() => {
loadSources();
}, 5000); // Poll every 5 seconds during active sync
return () => {
clearInterval(interval);
setAutoRefreshing(false);
};
} else {
setAutoRefreshing(false);
}
}, [sources]);
// Update default folders when source type changes
useEffect(() => {
if (!editingSource) { // Only for new sources
let defaultFolders;
switch (formData.source_type) {
case 'local_folder':
defaultFolders = ['/home/user/Documents'];
break;
case 's3':
defaultFolders = ['documents/'];
break;
case 'webdav':
default:
defaultFolders = ['/Documents'];
break;
}
setFormData(prev => ({ ...prev, watch_folders: defaultFolders }));
}
}, [formData.source_type, editingSource]);
const loadSources = async () => {
try {
const response = await api.get('/sources');
setSources(response.data);
} catch (error) {
console.error('Failed to load sources:', error);
showSnackbar(t('sources.errors.loadFailed'), 'error');
} finally {
setLoading(false);
}
};
const showSnackbar = (message: string, severity: SnackbarState['severity']) => {
setSnackbar({ open: true, message, severity });
};
// OCR Control Functions (Admin only)
const loadOcrStatus = async () => {
if (user?.role !== 'Admin') return;
try {
const response = await queueService.getOcrStatus();
setOcrStatus(response.data);
} catch (error) {
console.error('Failed to load OCR status:', error);
}
};
const handlePauseOcr = async () => {
if (user?.role !== 'Admin') return;
setOcrLoading(true);
try {
await queueService.pauseOcr();
await loadOcrStatus();
showSnackbar(t('sources.ocr.pausedSuccess'), 'success');
} catch (error) {
console.error('Failed to pause OCR:', error);
showSnackbar(t('sources.ocr.pauseFailed'), 'error');
} finally {
setOcrLoading(false);
}
};
const handleResumeOcr = async () => {
if (user?.role !== 'Admin') return;
setOcrLoading(true);
try {
await queueService.resumeOcr();
await loadOcrStatus();
showSnackbar(t('sources.ocr.resumedSuccess'), 'success');
} catch (error) {
console.error('Failed to resume OCR:', error);
showSnackbar(t('sources.ocr.resumeFailed'), 'error');
} finally {
setOcrLoading(false);
}
};
const handleCreateSource = () => {
setEditingSource(null);
setFormData({
name: '',
source_type: 'webdav',
enabled: true,
// WebDAV fields
server_url: '',
username: '',
password: '',
server_type: 'generic',
// Local Folder fields
recursive: true,
follow_symlinks: false,
// S3 fields
bucket_name: '',
region: 'us-east-1',
access_key_id: '',
secret_access_key: '',
endpoint_url: '',
prefix: '',
// Common fields
watch_folders: ['/Documents'],
file_extensions: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
auto_sync: false,
sync_interval_minutes: 60,
});
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,
// WebDAV fields
server_url: config.server_url || '',
username: config.username || '',
password: config.password || '',
server_type: config.server_type || 'generic',
// Local Folder fields
recursive: config.recursive !== undefined ? config.recursive : true,
follow_symlinks: config.follow_symlinks || false,
// S3 fields
bucket_name: config.bucket_name || '',
region: config.region || 'us-east-1',
access_key_id: config.access_key_id || '',
secret_access_key: config.secret_access_key || '',
endpoint_url: config.endpoint_url || '',
prefix: config.prefix || '',
// Common fields
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,
});
setCrawlEstimate(null);
setNewFolder('');
setNewExtension('');
setDialogOpen(true);
};
const handleSaveSource = async () => {
try {
let config = {};
// Build config based on source type
if (formData.source_type === 'webdav') {
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,
};
} else if (formData.source_type === 'local_folder') {
config = {
watch_folders: formData.watch_folders,
file_extensions: formData.file_extensions,
auto_sync: formData.auto_sync,
sync_interval_minutes: formData.sync_interval_minutes,
recursive: formData.recursive,
follow_symlinks: formData.follow_symlinks,
};
} else if (formData.source_type === 's3') {
config = {
bucket_name: formData.bucket_name,
region: formData.region,
access_key_id: formData.access_key_id,
secret_access_key: formData.secret_access_key,
endpoint_url: formData.endpoint_url,
prefix: formData.prefix,
watch_folders: formData.watch_folders,
file_extensions: formData.file_extensions,
auto_sync: formData.auto_sync,
sync_interval_minutes: formData.sync_interval_minutes,
};
}
if (editingSource) {
await api.put(`/sources/${editingSource.id}`, {
name: formData.name,
enabled: formData.enabled,
config,
});
showSnackbar(t('sources.messages.updateSuccess'), 'success');
} else {
await api.post('/sources', {
name: formData.name,
source_type: formData.source_type,
enabled: formData.enabled,
config,
});
showSnackbar(t('sources.messages.createSuccess'), 'success');
}
setDialogOpen(false);
loadSources();
} catch (error) {
console.error('Failed to save source:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific source errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_DUPLICATE_NAME)) {
showSnackbar(t('sources.errors.duplicateName'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
showSnackbar(t('sources.errors.invalidConfig'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar(t('sources.errors.authFailed'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar(t('sources.errors.connectionError'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
showSnackbar(t('sources.errors.invalidPath'), 'error');
} else {
showSnackbar(errorInfo.message || t('sources.errors.saveFailed'), 'error');
}
}
};
const handleDeleteSource = (source: Source) => {
setSourceToDelete(source);
setDeleteDialogOpen(true);
};
const handleDeleteCancel = () => {
setDeleteDialogOpen(false);
setSourceToDelete(null);
setDeleteLoading(false);
};
const handleDeleteConfirm = async () => {
if (!sourceToDelete) return;
setDeleteLoading(true);
try {
await api.delete(`/sources/${sourceToDelete.id}`);
showSnackbar(t('sources.messages.deleteSuccess'), 'success');
loadSources();
handleDeleteCancel();
} catch (error) {
console.error('Failed to delete source:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific delete errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
showSnackbar(t('sources.errors.notFound'), 'warning');
loadSources(); // Refresh the list
handleDeleteCancel();
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
showSnackbar(t('sources.errors.syncInProgress'), 'error');
} else {
showSnackbar(errorInfo.message || t('sources.errors.deleteFailed'), 'error');
}
setDeleteLoading(false);
}
};
const handleTestConnection = async () => {
setTestingConnection(true);
try {
let response;
if (formData.source_type === 'webdav') {
response = await api.post('/sources/test-connection', {
source_type: 'webdav',
config: {
server_url: formData.server_url,
username: formData.username,
password: formData.password,
server_type: formData.server_type,
watch_folders: formData.watch_folders,
file_extensions: formData.file_extensions,
}
});
} else if (formData.source_type === 'local_folder') {
response = await api.post('/sources/test-connection', {
source_type: 'local_folder',
config: {
watch_folders: formData.watch_folders,
file_extensions: formData.file_extensions,
recursive: formData.recursive,
follow_symlinks: formData.follow_symlinks,
}
});
} else if (formData.source_type === 's3') {
response = await api.post('/sources/test-connection', {
source_type: 's3',
config: {
bucket_name: formData.bucket_name,
region: formData.region,
access_key_id: formData.access_key_id,
secret_access_key: formData.secret_access_key,
endpoint_url: formData.endpoint_url,
prefix: formData.prefix,
}
});
}
if (response && response.data.success) {
showSnackbar(response.data.message || t('sources.messages.connectionSuccess'), 'success');
} else {
showSnackbar(response?.data.message || t('sources.errors.connectionFailed'), 'error');
}
} catch (error: any) {
console.error('Failed to test connection:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific connection test errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar(t('sources.errors.connectionFailedUrl'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar(t('sources.errors.authFailedCredentials'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_INVALID_PATH)) {
showSnackbar(t('sources.errors.invalidFolderPath'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONFIG_INVALID)) {
showSnackbar(t('sources.errors.invalidSettings'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NETWORK_TIMEOUT)) {
showSnackbar(t('sources.errors.timeout'), 'error');
} else {
showSnackbar(errorInfo.message || t('sources.errors.testConnectionFailed'), 'error');
}
} finally {
setTestingConnection(false);
}
};
// Open sync modal instead of directly triggering sync
const handleOpenSyncModal = (source: Source) => {
setSourceToSync(source);
setSyncModalOpen(true);
};
const handleCloseSyncModal = () => {
setSyncModalOpen(false);
setSourceToSync(null);
};
const handleQuickSync = async () => {
if (!sourceToSync) return;
setSyncingSource(sourceToSync.id);
handleCloseSyncModal();
try {
await sourcesService.triggerSync(sourceToSync.id);
showSnackbar(t('sources.messages.syncStartSuccess'), 'success');
setTimeout(loadSources, 1000);
} catch (error: any) {
console.error('Failed to trigger sync:', error);
const errorInfo = ErrorHelper.formatErrorForDisplay(error, true);
// Handle specific sync errors
if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_SYNC_IN_PROGRESS)) {
showSnackbar(t('sources.errors.alreadySyncing'), 'warning');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_CONNECTION_FAILED)) {
showSnackbar(t('sources.errors.cannotConnect'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_AUTH_FAILED)) {
showSnackbar(t('sources.errors.authFailedSource'), 'error');
} else if (ErrorHelper.isErrorCode(error, ErrorCodes.SOURCE_NOT_FOUND)) {
showSnackbar(t('sources.errors.sourceDeleted'), 'error');
loadSources(); // Refresh the sources list
} else {
showSnackbar(errorInfo.message || t('sources.errors.syncStartFailed'), 'error');
}
} finally {
setSyncingSource(null);
}
};
const handleDeepScan = async () => {
if (!sourceToSync) return;
setDeepScanning(true);
handleCloseSyncModal();
try {
await sourcesService.triggerDeepScan(sourceToSync.id);
showSnackbar(t('sources.messages.deepScanSuccess'), 'success');
setTimeout(loadSources, 1000);
} catch (error: any) {
console.error('Failed to trigger deep scan:', error);
if (error.response?.status === 409) {
showSnackbar('Source is already syncing', 'warning');
} else if (error.response?.status === 400 && error.response?.data?.message?.includes('only supported for WebDAV')) {
showSnackbar(t('sources.errors.deepScanWebdavOnly'), 'warning');
} else {
showSnackbar(t('sources.errors.deepScanFailed'), 'error');
}
} finally {
setDeepScanning(false);
}
};
const handleStopSync = async (sourceId: string) => {
setStoppingSync(sourceId);
try {
await sourcesService.stopSync(sourceId);
showSnackbar(t('sources.messages.syncStopSuccess'), 'success');
setTimeout(loadSources, 1000);
} catch (error: any) {
console.error('Failed to stop sync:', error);
if (error.response?.status === 409) {
showSnackbar(t('sources.errors.notSyncing'), 'warning');
} else {
showSnackbar(t('sources.errors.syncStopFailed'), 'error');
}
} finally {
setStoppingSync(null);
}
};
const handleValidation = async (sourceId: string) => {
setValidating(sourceId);
try {
const response = await api.post(`/sources/${sourceId}/validate`);
if (response.data.success) {
showSnackbar(response.data.message || 'Validation check started successfully', 'success');
setTimeout(loadSources, 2000); // Reload after 2 seconds to show updated status
} else {
showSnackbar(response.data.message || 'Failed to start validation check', 'error');
}
} catch (error: any) {
console.error('Failed to trigger validation:', error);
const message = error.response?.data?.message || 'Failed to start validation check';
showSnackbar(message, 'error');
} finally {
setValidating(null);
}
};
// Helper function to render validation status
const renderValidationStatus = (source: Source) => {
const validationStatus = source.validation_status;
const validationScore = source.validation_score;
const lastValidationAt = source.last_validation_at;
let statusColor = theme.palette.grey[500];
let StatusIcon = HealthIcon;
let statusText = t('sources.validation.unknown');
let tooltipText = t('sources.validation.statusUnknown');
if (validationStatus === 'healthy') {
statusColor = theme.palette.success.main;
StatusIcon = CheckCircleIcon;
statusText = t('sources.validation.healthy');
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') })`;
} else if (validationStatus === 'warning') {
statusColor = theme.palette.warning.main;
StatusIcon = WarningIcon;
statusText = t('sources.validation.warning');
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') }) - Issues detected`;
} else if (validationStatus === 'critical') {
statusColor = theme.palette.error.main;
StatusIcon = CriticalIcon;
statusText = t('sources.validation.critical');
tooltipText = `t('sources.validation.healthScore', { score: validationScore || t('sources.labels.notAvailable') }) - Critical issues`;
} else if (validationStatus === 'validating') {
statusColor = theme.palette.info.main;
StatusIcon = HealthIcon;
statusText = t('sources.validation.validating');
tooltipText = t('sources.validation.inProgress');
}
if (lastValidationAt) {
const lastValidation = new Date(lastValidationAt);
tooltipText += `\nLast checked: ${formatDistanceToNow(lastValidation)} ago`;
}
return (
<Tooltip title={tooltipText}>
<Chip
icon={<StatusIcon />}
label={statusText}
size="small"
sx={{
bgcolor: alpha(statusColor, 0.1),
color: statusColor,
borderColor: statusColor,
border: '1px solid',
'& .MuiChip-icon': {
color: statusColor,
},
}}
/>
</Tooltip>
);
};
// 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(t('sources.messages.estimationSuccess'), 'success');
} catch (error) {
console.error('Failed to estimate crawl:', error);
showSnackbar(t('sources.errors.estimateFailed'), 'error');
} finally {
setEstimatingCrawl(false);
}
};
const getSourceIcon = (sourceType: string) => {
switch (sourceType) {
case 'webdav':
return <CloudIcon />;
case 's3':
return <CloudIcon />;
case 'local_folder':
return <FolderIcon />;
default:
return <StorageIcon />;
}
};
const getStatusIcon = (source: Source) => {
if (source.status === 'syncing') {
return <SyncIcon sx={{ animation: 'spin 2s linear infinite' }} />;
} else if (source.status === 'error') {
return <ErrorIcon />;
} else {
return <CheckCircleIcon />;
}
};
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', tooltip }: {
icon: React.ReactNode;
label: string;
value: string | number;
color?: 'primary' | 'success' | 'warning' | 'error' | 'info';
tooltip?: string;
}) => {
const card = (
<Box
sx={{
p: 2.5,
borderRadius: 3,
background: `linear-gradient(135deg, ${alpha(theme.palette[color].main, 0.1)} 0%, ${alpha(theme.palette[color].main, 0.05)} 100%)`,
border: `1px solid ${alpha(theme.palette[color].main, 0.2)}`,
position: 'relative',
overflow: 'hidden',
height: '100px',
display: 'flex',
alignItems: 'center',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: `linear-gradient(90deg, ${theme.palette[color].main}, ${theme.palette[color].light})`,
}
}}
>
<Stack direction="row" alignItems="center" spacing={2} sx={{ width: '100%', overflow: 'hidden' }}>
<Avatar
sx={{
bgcolor: alpha(theme.palette[color].main, 0.15),
color: theme.palette[color].main,
width: 40,
height: 40,
flexShrink: 0
}}
>
{icon}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="h5"
fontWeight="bold"
color={theme.palette[color].main}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{typeof value === 'number' ? value.toLocaleString() : value}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.75rem'
}}
>
{label}
</Typography>
</Box>
</Stack>
</Box>
);
return tooltip ? (
<Tooltip title={tooltip} arrow>
{card}
</Tooltip>
) : card;
};
const renderSourceCard = (source: Source) => (
<Fade in={true} key={source.id}>
<Box>
<Card
data-testid="source-item"
sx={{
position: 'relative',
overflow: 'hidden',
borderRadius: 4,
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: theme.shadows[8],
'& .action-buttons': {
opacity: 1,
transform: 'translateY(0)',
}
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '4px',
background: source.enabled
? `linear-gradient(90deg, ${getStatusColor(source.status)}, ${alpha(getStatusColor(source.status), 0.7)})`
: theme.palette.grey[300],
}
}}
>
<CardContent sx={{ p: 4 }}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" mb={3}>
<Stack direction="row" alignItems="center" spacing={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
width: 56,
height: 56,
}}
>
{getSourceIcon(source.source_type)}
</Avatar>
<Box>
<Typography variant="h6" fontWeight="bold" gutterBottom>
{source.name}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Chip
label={source.source_type.toUpperCase()}
size="small"
variant="outlined"
sx={{
borderRadius: 2,
fontSize: '0.75rem',
fontWeight: 600,
}}
/>
<Chip
icon={getStatusIcon(source)}
label={source.status.charAt(0).toUpperCase() + source.status.slice(1)}
size="small"
sx={{
borderRadius: 2,
bgcolor: alpha(getStatusColor(source.status), 0.1),
color: getStatusColor(source.status),
border: `1px solid ${alpha(getStatusColor(source.status), 0.3)}`,
fontSize: '0.75rem',
fontWeight: 600,
}}
/>
<Chip
icon={<DocumentIcon sx={{ fontSize: '0.9rem !important' }} />}
label={`${source.total_documents} docs`}
size="small"
sx={{
borderRadius: 2,
bgcolor: alpha(theme.palette.info.main, 0.1),
color: theme.palette.info.main,
border: `1px solid ${alpha(theme.palette.info.main, 0.3)}`,
fontSize: '0.75rem',
fontWeight: 600,
}}
/>
<Chip
icon={<OcrIcon sx={{ fontSize: '0.9rem !important' }} />}
label={t('sources.stats.ocrCount', { count: source.total_documents_ocr })}
size="small"
sx={{
borderRadius: 2,
bgcolor: alpha(theme.palette.success.main, 0.1),
color: theme.palette.success.main,
border: `1px solid ${alpha(theme.palette.success.main, 0.3)}`,
fontSize: '0.75rem',
fontWeight: 600,
}}
/>
{!source.enabled && (
<Chip
label="Disabled"
size="small"
color="default"
sx={{ borderRadius: 2, fontSize: '0.75rem' }}
/>
)}
</Stack>
</Box>
</Stack>
{/* Action Buttons */}
<Stack
direction="row"
spacing={1}
className="action-buttons"
sx={{
opacity: 0,
transform: 'translateY(-8px)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{source.status === 'syncing' ? (
<Tooltip title="Stop Sync">
<span>
<IconButton
onClick={() => handleStopSync(source.id)}
disabled={stoppingSync === source.id}
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
'&:hover': { bgcolor: alpha(theme.palette.warning.main, 0.2) },
color: theme.palette.warning.main,
}}
>
{stoppingSync === source.id ? (
<CircularProgress size={20} />
) : (
<StopIcon />
)}
</IconButton>
</span>
</Tooltip>
) : (
<Tooltip title="Trigger Sync">
<span>
<IconButton
data-testid="sync-button"
onClick={() => handleOpenSyncModal(source)}
disabled={syncingSource === source.id || deepScanning || !source.enabled}
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
'&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.2) },
}}
>
{syncingSource === source.id ? (
<CircularProgress size={20} />
) : (
<PlayArrowIcon data-testid="PlayArrowIcon" />
)}
</IconButton>
</span>
</Tooltip>
)}
{/* Validation Status Display */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 120 }}>
{renderValidationStatus(source)}
<Tooltip title="Run Validation Check">
<IconButton
onClick={() => handleValidation(source.id)}
disabled={validating === source.id || source.status === 'syncing' || !source.enabled}
size="small"
sx={{
bgcolor: alpha(theme.palette.info.main, 0.1),
'&:hover': { bgcolor: alpha(theme.palette.info.main, 0.2) },
color: theme.palette.info.main,
}}
>
{validating === source.id ? (
<CircularProgress size={16} />
) : (
<HealthIcon />
)}
</IconButton>
</Tooltip>
</Box>
<Tooltip title="Edit Source">
<IconButton
onClick={() => handleEditSource(source)}
sx={{
bgcolor: alpha(theme.palette.grey[500], 0.1),
'&:hover': { bgcolor: alpha(theme.palette.grey[500], 0.2) },
}}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="View Ignored Files">
<IconButton
onClick={() => navigate(`/ignored-files?sourceType=${source.source_type}&sourceName=${encodeURIComponent(source.name)}&sourceId=${source.id}`)}
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
'&:hover': { bgcolor: alpha(theme.palette.warning.main, 0.2) },
color: theme.palette.warning.main,
}}
>
<BlockIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Source">
<IconButton
onClick={() => 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,
}}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
{/* Stats Grid */}
<Grid container spacing={2} mb={3}>
<Grid item xs={6} sm={4} md={2.4}>
<StatCard
icon={<DocumentIcon />}
label="Documents Stored"
value={source.total_documents}
color="info"
tooltip="Total number of documents currently stored from this source"
/>
</Grid>
<Grid item xs={6} sm={4} md={2.4}>
<StatCard
icon={<OcrIcon />}
label="OCR Processed"
value={source.total_documents_ocr}
color="success"
tooltip="Number of documents that have been successfully OCR'd"
/>
</Grid>
<Grid item xs={6} sm={4} md={2.4}>
<StatCard
icon={<TimelineIcon />}
label="Last Sync"
value={source.last_sync_at
? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true })
: t('sources.stats.never')}
color="primary"
tooltip="When this source was last synchronized"
/>
</Grid>
<Grid item xs={6} sm={4} md={2.4}>
<StatCard
icon={<SpeedIcon />}
label="Files Pending"
value={source.total_files_pending}
color="warning"
tooltip="Files discovered but not yet processed during sync"
/>
</Grid>
<Grid item xs={6} sm={4} md={2.4}>
<StatCard
icon={<StorageIcon />}
label="Total Size"
value={formatBytes(source.total_size_bytes)}
color="primary"
tooltip="Total size of files successfully downloaded from this source"
/>
</Grid>
</Grid>
{/* Sync Progress Display */}
<SyncProgressDisplay
sourceId={source.id}
sourceName={source.name}
isVisible={source.status === 'syncing'}
/>
{/* Error Alert */}
{source.last_error && (
<Alert
severity="error"
sx={{
borderRadius: 3,
'& .MuiAlert-icon': {
fontSize: '1.2rem',
}
}}
>
<Typography variant="body2" fontWeight="medium">
{source.last_error}
</Typography>
{source.last_error_at && (
<Typography variant="caption" display="block" sx={{ mt: 0.5, opacity: 0.8 }}>
{formatDistanceToNow(new Date(source.last_error_at), { addSuffix: true })}
</Typography>
)}
</Alert>
)}
</CardContent>
</Card>
</Box>
</Fade>
);
return (
<Container maxWidth="xl" sx={{ py: 6 }}>
{/* Header */}
<Box sx={{ mb: 6 }}>
<Typography
variant="h3"
component="h1"
fontWeight="bold"
sx={{
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
mb: 2,
}}
>
Document Sources
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 4 }}>
Connect and manage your document sources with intelligent syncing
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<Button
variant="contained"
size="large"
startIcon={<AddIcon />}
onClick={handleCreateSource}
data-testid="add-source"
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
Add Source
</Button>
<Button
variant="outlined"
size="large"
startIcon={autoRefreshing ? <CircularProgress size={20} /> : <AutoFixHighIcon />}
onClick={loadSources}
disabled={autoRefreshing}
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
transform: 'translateY(-1px)',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{autoRefreshing ? t('sources.status.autoRefreshing') : t('common.actions.refresh')}
</Button>
{/* OCR Controls for Admin Users */}
{user?.role === 'Admin' && (
<>
{ocrLoading ? (
<CircularProgress size={24} />
) : ocrStatus.is_paused ? (
<Button
variant="outlined"
size="large"
startIcon={<ResumeIcon />}
onClick={handleResumeOcr}
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
borderWidth: 2,
borderColor: 'success.main',
color: 'success.main',
'&:hover': {
borderWidth: 2,
borderColor: 'success.dark',
backgroundColor: alpha(theme.palette.success.main, 0.1),
transform: 'translateY(-1px)',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
Resume OCR
</Button>
) : (
<Button
variant="outlined"
size="large"
startIcon={<PauseIcon />}
onClick={handlePauseOcr}
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
borderWidth: 2,
borderColor: 'warning.main',
color: 'warning.main',
'&:hover': {
borderWidth: 2,
borderColor: 'warning.dark',
backgroundColor: alpha(theme.palette.warning.main, 0.1),
transform: 'translateY(-1px)',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
Pause OCR
</Button>
)}
</>
)}
</Stack>
</Box>
{/* Content */}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={48} thickness={3} />
</Box>
) : sources.length === 0 ? (
<Paper
sx={{
p: 8,
textAlign: 'center',
borderRadius: 4,
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.secondary.main, 0.05)} 100%)`,
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
}}
>
<Avatar
sx={{
width: 80,
height: 80,
mx: 'auto',
mb: 3,
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
}}
>
<StorageIcon sx={{ fontSize: 40 }} />
</Avatar>
<Typography variant="h5" fontWeight="bold" gutterBottom>
No Sources Configured
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 400, mx: 'auto' }}>
Connect your first document source to start automatically syncing and processing your files with AI-powered OCR.
</Typography>
<Button
variant="contained"
size="large"
startIcon={<AddIcon />}
onClick={handleCreateSource}
sx={{
borderRadius: 3,
px: 6,
py: 2,
fontSize: '1.1rem',
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.3)}`,
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.4)}`,
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
Add Your First Source
</Button>
</Paper>
) : (
<Grid container spacing={4} data-testid="sources-list">
{sources.map(renderSourceCard)}
</Grid>
)}
{/* Create/Edit Dialog - Enhanced */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
background: theme.palette.background.paper,
}
}}
>
<DialogTitle sx={{ p: 4, pb: 2 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
}}
>
{editingSource ? <EditIcon /> : <AddIcon />}
</Avatar>
<Box>
<Typography variant="h6" fontWeight="bold">
{editingSource ? t('sources.actions.editSource') : t('sources.dialog.createTitle')}
</Typography>
<Typography variant="body2" color="text.secondary">
{editingSource ? t('sources.dialog.editSubtitle') : t('sources.dialog.createSubtitle')}
</Typography>
</Box>
</Stack>
</DialogTitle>
<DialogContent sx={{ p: 4, pt: 2 }}>
<Stack spacing={3}>
<TextField
fullWidth
label="Source Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Document Server"
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
{!editingSource && (
<FormControl fullWidth>
<InputLabel>Source Type</InputLabel>
<Select
value={formData.source_type}
onChange={(e) => setFormData({ ...formData, source_type: e.target.value as any })}
label="Source Type"
sx={{
borderRadius: 2,
}}
>
<MenuItem value="webdav">
<Stack direction="row" alignItems="center" spacing={2}>
<CloudIcon />
<Box>
<Typography variant="body1">WebDAV</Typography>
<Typography variant="caption" color="text.secondary">
Nextcloud, ownCloud, and other WebDAV servers
</Typography>
</Box>
</Stack>
</MenuItem>
<MenuItem value="local_folder">
<Stack direction="row" alignItems="center" spacing={2}>
<FolderIcon />
<Box>
<Typography variant="body1">Local Folder</Typography>
<Typography variant="caption" color="text.secondary">
Monitor local filesystem directories
</Typography>
</Box>
</Stack>
</MenuItem>
<MenuItem value="s3">
<Stack direction="row" alignItems="center" spacing={2}>
<CloudIcon />
<Box>
<Typography variant="body1">S3 Compatible</Typography>
<Typography variant="caption" color="text.secondary">
AWS S3, MinIO, and other S3-compatible storage
</Typography>
</Box>
</Stack>
</MenuItem>
</Select>
</FormControl>
)}
{formData.source_type === 'webdav' && (
<Paper
sx={{
p: 3,
borderRadius: 3,
bgcolor: alpha(theme.palette.primary.main, 0.03),
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
}}
>
<Stack spacing={3}>
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
width: 32,
height: 32,
}}
>
<CloudIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
WebDAV Configuration
</Typography>
</Stack>
<TextField
fullWidth
label="Server URL"
value={formData.server_url}
onChange={(e) => setFormData({ ...formData, server_url: e.target.value })}
placeholder={
formData.server_type === 'nextcloud'
? "https://nextcloud.example.com/"
: formData.server_type === 'owncloud'
? "https://owncloud.example.com/remote.php/dav/files/username/"
: "https://webdav.example.com/dav/"
}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
</Grid>
<FormControl fullWidth>
<InputLabel>Server Type</InputLabel>
<Select
value={formData.server_type}
onChange={(e) => setFormData({ ...formData, server_type: e.target.value as any })}
label="Server Type"
sx={{ borderRadius: 2 }}
>
<MenuItem value="nextcloud">
<Stack direction="row" alignItems="center" spacing={2}>
<ServerIcon />
<Box>
<Typography variant="body1">Nextcloud</Typography>
<Typography variant="caption" color="text.secondary">
Optimized for Nextcloud servers
</Typography>
</Box>
</Stack>
</MenuItem>
<MenuItem value="owncloud">
<Stack direction="row" alignItems="center" spacing={2}>
<ServerIcon />
<Box>
<Typography variant="body1">ownCloud</Typography>
<Typography variant="caption" color="text.secondary">
Optimized for ownCloud servers
</Typography>
</Box>
</Stack>
</MenuItem>
<MenuItem value="generic">
<Stack direction="row" alignItems="center" spacing={2}>
<CloudIcon />
<Box>
<Typography variant="body1">Generic WebDAV</Typography>
<Typography variant="caption" color="text.secondary">
Standard WebDAV protocol
</Typography>
</Box>
</Stack>
</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={formData.auto_sync}
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Enable Automatic Sync
</Typography>
<Typography variant="caption" color="text.secondary">
Automatically sync files on a schedule
</Typography>
</Box>
}
/>
{formData.auto_sync && (
<TextField
fullWidth
type="number"
label="Sync Interval (minutes)"
value={formData.sync_interval_minutes}
onChange={(e) => 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 } }}
/>
)}
<Divider sx={{ my: 2 }} />
{/* Folder Management */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
width: 32,
height: 32,
}}
>
<FolderIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
Folders to Monitor
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Specify which folders to scan for files. Use absolute paths starting with "/".
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Folder Path"
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
placeholder="/Documents"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFolder}
disabled={!newFolder}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.watch_folders.map((folder, index) => (
<Chip
key={index}
label={folder}
onDelete={() => removeFolder(folder)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
}}
/>
))}
</Box>
{/* File Extensions */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
width: 32,
height: 32,
}}
>
<ExtensionIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
File Extensions
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
File types to sync and process with OCR.
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Extension"
value={newExtension}
onChange={(e) => setNewExtension(e.target.value)}
placeholder="docx"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFileExtension}
disabled={!newExtension}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.file_extensions.map((extension, index) => (
<Chip
key={index}
label={extension}
onDelete={() => removeFileExtension(extension)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
}}
/>
))}
</Box>
{/* Crawl Estimation */}
{editingSource && formData.server_url && formData.username && formData.watch_folders.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.info.main, 0.1),
color: theme.palette.info.main,
width: 32,
height: 32,
}}
>
<AssessmentIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
Crawl Estimation
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Estimate how many files will be processed and how long it will take.
</Typography>
<Button
variant="outlined"
onClick={estimateCrawl}
disabled={estimatingCrawl}
startIcon={estimatingCrawl ? <CircularProgress size={20} /> : <AssessmentIcon />}
sx={{ mb: 2, borderRadius: 2 }}
>
{estimatingCrawl ? t('sources.estimation.estimating') : t('sources.estimation.estimate')}
</Button>
{estimatingCrawl && (
<Box sx={{ mb: 2 }}>
<LinearProgress sx={{ borderRadius: 1 }} />
<Typography variant="body2" sx={{ mt: 1 }}>
Analyzing folders and counting files...
</Typography>
</Box>
)}
{crawlEstimate && (
<Paper
sx={{
p: 3,
borderRadius: 3,
bgcolor: alpha(theme.palette.info.main, 0.03),
border: `1px solid ${alpha(theme.palette.info.main, 0.1)}`,
mb: 2
}}
>
<Typography variant="h6" sx={{ mb: 2 }}>
Estimation Results
</Typography>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{crawlEstimate.total_files?.toLocaleString() || '0'}
</Typography>
<Typography variant="body2">Total Files</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success.main">
{crawlEstimate.total_supported_files?.toLocaleString() || '0'}
</Typography>
<Typography variant="body2">Supported Files</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="warning.main">
{crawlEstimate.total_estimated_time_hours?.toFixed(1) || '0'}h
</Typography>
<Typography variant="body2">Estimated Time</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info.main">
{crawlEstimate.total_size_mb ? (crawlEstimate.total_size_mb / 1024).toFixed(1) : '0'}GB
</Typography>
<Typography variant="body2">Total Size</Typography>
</Box>
</Grid>
</Grid>
{crawlEstimate.folders && crawlEstimate.folders.length > 0 && (
<TableContainer component={Paper} sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Folder</TableCell>
<TableCell align="right">Total Files</TableCell>
<TableCell align="right">Supported</TableCell>
<TableCell align="right">Est. Time</TableCell>
<TableCell align="right">Size (MB)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{crawlEstimate.folders.map((folder: any) => (
<TableRow key={folder.path}>
<TableCell>{folder.path}</TableCell>
<TableCell align="right">{folder.total_files?.toLocaleString() || '0'}</TableCell>
<TableCell align="right">{folder.supported_files?.toLocaleString() || '0'}</TableCell>
<TableCell align="right">{folder.estimated_time_hours?.toFixed(1) || '0'}h</TableCell>
<TableCell align="right">{folder.total_size_mb?.toFixed(1) || '0'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
)}
</>
)}
</Stack>
</Paper>
)}
{formData.source_type === 'local_folder' && (
<Paper
sx={{
p: 3,
borderRadius: 3,
bgcolor: alpha(theme.palette.warning.main, 0.03),
border: `1px solid ${alpha(theme.palette.warning.main, 0.1)}`,
}}
>
<Stack spacing={3}>
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
width: 32,
height: 32,
}}
>
<FolderIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
Local Folder Configuration
</Typography>
</Stack>
<Alert severity="info" sx={{ borderRadius: 2 }}>
<Typography variant="body2">
Monitor local filesystem directories for new documents.
Ensure the application has read access to the specified paths.
</Typography>
</Alert>
<FormControlLabel
control={
<Switch
checked={formData.recursive}
onChange={(e) => setFormData({ ...formData, recursive: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Recursive Scanning
</Typography>
<Typography variant="caption" color="text.secondary">
Scan subdirectories recursively
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={formData.follow_symlinks}
onChange={(e) => setFormData({ ...formData, follow_symlinks: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Follow Symbolic Links
</Typography>
<Typography variant="caption" color="text.secondary">
Follow symlinks when scanning directories
</Typography>
</Box>
}
/>
<FormControlLabel
control={
<Switch
checked={formData.auto_sync}
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Enable Automatic Sync
</Typography>
<Typography variant="caption" color="text.secondary">
Automatically scan for new files on a schedule
</Typography>
</Box>
}
/>
{formData.auto_sync && (
<TextField
fullWidth
type="number"
label="Sync Interval (minutes)"
value={formData.sync_interval_minutes}
onChange={(e) => setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })}
inputProps={{ min: 15, max: 1440 }}
helperText="How often to scan for new files (15 min - 24 hours)"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
)}
<Divider sx={{ my: 2 }} />
{/* Folder Management */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
width: 32,
height: 32,
}}
>
<FolderIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
Directories to Monitor
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Specify which local directories to scan for files. Use absolute paths.
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Directory Path"
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
placeholder="/home/user/Documents"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFolder}
disabled={!newFolder}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.watch_folders.map((folder, index) => (
<Chip
key={index}
label={folder}
onDelete={() => removeFolder(folder)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
}}
/>
))}
</Box>
{/* File Extensions */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
width: 32,
height: 32,
}}
>
<ExtensionIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
File Extensions
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
File types to monitor and process with OCR.
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Extension"
value={newExtension}
onChange={(e) => setNewExtension(e.target.value)}
placeholder="docx"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFileExtension}
disabled={!newExtension}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.file_extensions.map((extension, index) => (
<Chip
key={index}
label={extension}
onDelete={() => removeFileExtension(extension)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
}}
/>
))}
</Box>
</Stack>
</Paper>
)}
{formData.source_type === 's3' && (
<Paper
sx={{
p: 3,
borderRadius: 3,
bgcolor: alpha(theme.palette.success.main, 0.03),
border: `1px solid ${alpha(theme.palette.success.main, 0.1)}`,
}}
>
<Stack spacing={3}>
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.success.main, 0.1),
color: theme.palette.success.main,
width: 32,
height: 32,
}}
>
<CloudIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
S3 Compatible Storage Configuration
</Typography>
</Stack>
<Alert severity="info" sx={{ borderRadius: 2 }}>
<Typography variant="body2">
Connect to AWS S3, MinIO, or any S3-compatible storage service.
For MinIO, provide the endpoint URL of your server.
</Typography>
</Alert>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Bucket Name"
value={formData.bucket_name}
onChange={(e) => setFormData({ ...formData, bucket_name: e.target.value })}
placeholder="my-documents-bucket"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Region"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder="us-east-1"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Access Key ID"
value={formData.access_key_id}
onChange={(e) => setFormData({ ...formData, access_key_id: e.target.value })}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Secret Access Key"
type="password"
value={formData.secret_access_key}
onChange={(e) => setFormData({ ...formData, secret_access_key: e.target.value })}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Grid>
</Grid>
<TextField
fullWidth
label="Endpoint URL (Optional)"
value={formData.endpoint_url}
onChange={(e) => setFormData({ ...formData, endpoint_url: e.target.value })}
placeholder="https://minio.example.com (for MinIO/S3-compatible services)"
helperText="Leave empty for AWS S3, or provide custom endpoint for MinIO/other S3-compatible storage"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
<TextField
fullWidth
label="Object Key Prefix (Optional)"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="documents/"
helperText="Optional prefix to limit scanning to specific object keys"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
<FormControlLabel
control={
<Switch
checked={formData.auto_sync}
onChange={(e) => setFormData({ ...formData, auto_sync: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Enable Automatic Sync
</Typography>
<Typography variant="caption" color="text.secondary">
Automatically check for new objects on a schedule
</Typography>
</Box>
}
/>
{formData.auto_sync && (
<TextField
fullWidth
type="number"
label="Sync Interval (minutes)"
value={formData.sync_interval_minutes}
onChange={(e) => setFormData({ ...formData, sync_interval_minutes: parseInt(e.target.value) || 60 })}
inputProps={{ min: 15, max: 1440 }}
helperText="How often to check for new objects (15 min - 24 hours)"
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
)}
<Divider sx={{ my: 2 }} />
{/* Folder Management (prefixes for S3) */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
width: 32,
height: 32,
}}
>
<FolderIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
Object Prefixes to Monitor
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Specify which object prefixes (like folders) to scan for files.
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Object Prefix"
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
placeholder="documents/"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFolder}
disabled={!newFolder}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.watch_folders.map((folder, index) => (
<Chip
key={index}
label={folder}
onDelete={() => removeFolder(folder)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.secondary.main, 0.1),
color: theme.palette.secondary.main,
}}
/>
))}
</Box>
{/* File Extensions */}
<Stack direction="row" alignItems="center" spacing={2} mb={2}>
<Avatar
sx={{
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
width: 32,
height: 32,
}}
>
<ExtensionIcon />
</Avatar>
<Typography variant="h6" fontWeight="medium">
File Extensions
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
File types to sync and process with OCR.
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<TextField
label="Add Extension"
value={newExtension}
onChange={(e) => setNewExtension(e.target.value)}
placeholder="docx"
sx={{
flexGrow: 1,
'& .MuiOutlinedInput-root': { borderRadius: 2 }
}}
/>
<Button
variant="outlined"
onClick={addFileExtension}
disabled={!newExtension}
sx={{ borderRadius: 2, px: 3 }}
>
Add
</Button>
</Stack>
<Box sx={{ mb: 3 }}>
{formData.file_extensions.map((extension, index) => (
<Chip
key={index}
label={extension}
onDelete={() => removeFileExtension(extension)}
sx={{
mr: 1,
mb: 1,
borderRadius: 2,
bgcolor: alpha(theme.palette.warning.main, 0.1),
color: theme.palette.warning.main,
}}
/>
))}
</Box>
</Stack>
</Paper>
)}
<FormControlLabel
control={
<Switch
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight="medium">
Source Enabled
</Typography>
<Typography variant="caption" color="text.secondary">
Enable this source for syncing
</Typography>
</Box>
}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 4, pt: 2 }}>
<Button
onClick={() => setDialogOpen(false)}
sx={{ borderRadius: 2 }}
>
Cancel
</Button>
{(formData.source_type === 'webdav' || formData.source_type === 'local_folder' || formData.source_type === 's3') && (
<Button
onClick={handleTestConnection}
disabled={testingConnection ||
(formData.source_type === 'webdav' && (!formData.server_url || !formData.username)) ||
(formData.source_type === 'local_folder' && formData.watch_folders.length === 0) ||
(formData.source_type === 's3' && (!formData.bucket_name || !formData.access_key_id || !formData.secret_access_key))
}
startIcon={testingConnection ? <CircularProgress size={20} /> : <SecurityIcon />}
sx={{ borderRadius: 2 }}
>
Test Connection
</Button>
)}
<Button
onClick={handleSaveSource}
variant="contained"
sx={{
borderRadius: 2,
px: 4,
background: `linear-gradient(45deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`,
}}
>
{editingSource ? 'Save Changes' : t('sources.actions.createSource')}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleDeleteCancel}
maxWidth="sm"
fullWidth
>
<DialogTitle>Delete Source</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete "{sourceToDelete?.name}"?
</DialogContentText>
<DialogContentText variant="body2" color="text.secondary" sx={{ mt: 1 }}>
This action cannot be undone. The source configuration and all associated sync history will be permanently removed.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleteLoading}>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteLoading}
sx={{
borderRadius: 2,
px: 3,
}}
>
{deleteLoading ? t('sources.delete.deleting') : t('common.actions.delete')}
</Button>
</DialogActions>
</Dialog>
{/* Sync Type Selection Modal */}
<Dialog
open={syncModalOpen}
onClose={handleCloseSyncModal}
maxWidth="sm"
fullWidth
>
<DialogTitle sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SyncIcon color="primary" />
Choose Sync Type
</Box>
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{sourceToSync && (
<>
Select the type of synchronization for <strong>{sourceToSync.name}</strong>:
</>
)}
</DialogContentText>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Card
sx={{
cursor: 'pointer',
border: '2px solid transparent',
transition: 'all 0.2s',
'&:hover': {
borderColor: 'primary.main',
bgcolor: 'action.hover',
},
}}
onClick={handleQuickSync}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<QuickSyncIcon
sx={{
fontSize: 48,
color: 'primary.main',
mb: 2,
}}
/>
<Typography variant="h6" gutterBottom>
Quick Sync
</Typography>
<Typography variant="body2" color="text.secondary">
Fast incremental sync using ETags. Only processes new or changed files.
</Typography>
<Box sx={{ mt: 2 }}>
<Chip label="Recommended" color="primary" size="small" />
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6}>
<Card
sx={{
cursor: sourceToSync?.source_type === 'webdav' ? 'pointer' : 'not-allowed',
border: '2px solid transparent',
transition: 'all 0.2s',
opacity: sourceToSync?.source_type === 'webdav' ? 1 : 0.6,
'&:hover': sourceToSync?.source_type === 'webdav' ? {
borderColor: 'warning.main',
bgcolor: 'action.hover',
} : {},
}}
onClick={sourceToSync?.source_type === 'webdav' ? handleDeepScan : undefined}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<DeepScanIcon
sx={{
fontSize: 48,
color: sourceToSync?.source_type === 'webdav' ? 'warning.main' : 'text.disabled',
mb: 2,
}}
/>
<Typography variant="h6" gutterBottom>
Deep Scan
</Typography>
<Typography variant="body2" color="text.secondary">
Complete rescan that resets ETag expectations. Use for troubleshooting sync issues.
</Typography>
<Box sx={{ mt: 2 }}>
{sourceToSync?.source_type === 'webdav' ? (
<Chip label="WebDAV Only" color="warning" size="small" />
) : (
<Chip label="Not Available" color="default" size="small" />
)}
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{sourceToSync?.source_type !== 'webdav' && (
<Alert severity="info" sx={{ mt: 2 }}>
Deep scan is currently only available for WebDAV sources. Other source types will use quick sync.
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseSyncModal}>
Cancel
</Button>
</DialogActions>
</Dialog>
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={() => setSnackbar({ ...snackbar, open: false })}
severity={snackbar.severity}
sx={{
width: '100%',
borderRadius: 3,
}}
>
{snackbar.message}
</Alert>
</Snackbar>
{/* Custom CSS for animations */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</Container>
);
};
export default SourcesPage;