1331 lines
47 KiB
TypeScript
1331 lines
47 KiB
TypeScript
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<Source[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingSource, setEditingSource] = useState<Source | null>(null);
|
|
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,
|
|
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<any>(null);
|
|
const [estimatingCrawl, setEstimatingCrawl] = useState(false);
|
|
|
|
const [testingConnection, setTestingConnection] = useState(false);
|
|
const [syncingSource, setSyncingSource] = useState<string | null>(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 <CloudIcon />;
|
|
case 's3':
|
|
return <StorageIcon />;
|
|
case 'local_folder':
|
|
return <StorageIcon />;
|
|
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' }: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string | number;
|
|
color?: 'primary' | 'success' | 'warning' | 'error'
|
|
}) => (
|
|
<Box
|
|
sx={{
|
|
p: 3,
|
|
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',
|
|
'&::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}>
|
|
<Avatar
|
|
sx={{
|
|
bgcolor: alpha(theme.palette[color].main, 0.15),
|
|
color: theme.palette[color].main,
|
|
width: 48,
|
|
height: 48,
|
|
}}
|
|
>
|
|
{icon}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h4" fontWeight="bold" color={theme.palette[color].main}>
|
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{label}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
|
|
const renderSourceCard = (source: Source) => (
|
|
<Fade in={true} key={source.id}>
|
|
<Card
|
|
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">
|
|
<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,
|
|
}}
|
|
/>
|
|
{!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)',
|
|
}}
|
|
>
|
|
<Tooltip title="Trigger Sync">
|
|
<span>
|
|
<IconButton
|
|
onClick={() => 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 ? (
|
|
<CircularProgress size={20} />
|
|
) : (
|
|
<PlayArrowIcon />
|
|
)}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<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="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={3} mb={3}>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<TrendingUpIcon />}
|
|
label="Files Synced"
|
|
value={source.total_files_synced}
|
|
color="success"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<SpeedIcon />}
|
|
label="Files Pending"
|
|
value={source.total_files_pending}
|
|
color="warning"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<StorageIcon />}
|
|
label="Total Size"
|
|
value={formatBytes(source.total_size_bytes)}
|
|
color="primary"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6} sm={3}>
|
|
<StatCard
|
|
icon={<TimelineIcon />}
|
|
label="Last Sync"
|
|
value={source.last_sync_at
|
|
? formatDistanceToNow(new Date(source.last_sync_at), { addSuffix: true })
|
|
: 'Never'}
|
|
color="primary"
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* 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>
|
|
</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}
|
|
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={<AutoFixHighIcon />}
|
|
onClick={loadSources}
|
|
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)',
|
|
}}
|
|
>
|
|
Refresh
|
|
</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}>
|
|
{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 ? 'Edit Source' : 'Create New Source'}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{editingSource ? 'Update your source configuration' : 'Connect a new document source'}
|
|
</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" disabled>
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<StorageIcon />
|
|
<Box>
|
|
<Typography variant="body1">Local Folder</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Coming Soon
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</MenuItem>
|
|
<MenuItem value="s3" disabled>
|
|
<Stack direction="row" alignItems="center" spacing={2}>
|
|
<CloudIcon />
|
|
<Box>
|
|
<Typography variant="body1">S3 Compatible</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Coming Soon
|
|
</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="https://nextcloud.example.com/remote.php/dav/files/username/"
|
|
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 ? 'Estimating...' : 'Estimate Crawl'}
|
|
</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>
|
|
)}
|
|
|
|
<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>
|
|
{editingSource && formData.source_type === 'webdav' && (
|
|
<Button
|
|
onClick={handleTestConnection}
|
|
disabled={testingConnection}
|
|
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' : 'Create Source'}
|
|
</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; |