1181 lines
47 KiB
TypeScript
1181 lines
47 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
Box,
|
||
Container,
|
||
Typography,
|
||
Paper,
|
||
Tabs,
|
||
Tab,
|
||
FormControl,
|
||
FormControlLabel,
|
||
InputLabel,
|
||
Select,
|
||
MenuItem,
|
||
Button,
|
||
Snackbar,
|
||
Alert,
|
||
TextField,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
IconButton,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
Card,
|
||
CardContent,
|
||
Divider,
|
||
Switch,
|
||
SelectChangeEvent,
|
||
Chip,
|
||
LinearProgress,
|
||
CircularProgress,
|
||
} from '@mui/material';
|
||
import Grid from '@mui/material/GridLegacy';
|
||
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
|
||
CloudSync as CloudSyncIcon, Folder as FolderIcon,
|
||
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon,
|
||
Pause as PauseIcon, Stop as StopIcon } from '@mui/icons-material';
|
||
import { useAuth } from '../contexts/AuthContext';
|
||
import api, { queueService } from '../services/api';
|
||
|
||
interface User {
|
||
id: string;
|
||
username: string;
|
||
email: string;
|
||
created_at: string;
|
||
}
|
||
|
||
interface Settings {
|
||
ocrLanguage: string;
|
||
concurrentOcrJobs: number;
|
||
ocrTimeoutSeconds: number;
|
||
maxFileSizeMb: number;
|
||
allowedFileTypes: string[];
|
||
autoRotateImages: boolean;
|
||
enableImagePreprocessing: boolean;
|
||
searchResultsPerPage: number;
|
||
searchSnippetLength: number;
|
||
fuzzySearchThreshold: number;
|
||
retentionDays: number | null;
|
||
enableAutoCleanup: boolean;
|
||
enableCompression: boolean;
|
||
memoryLimitMb: number;
|
||
cpuPriority: string;
|
||
enableBackgroundOcr: boolean;
|
||
ocrPageSegmentationMode: number;
|
||
ocrEngineMode: number;
|
||
ocrMinConfidence: number;
|
||
ocrDpi: number;
|
||
ocrEnhanceContrast: boolean;
|
||
ocrRemoveNoise: boolean;
|
||
ocrDetectOrientation: boolean;
|
||
ocrWhitelistChars: string;
|
||
ocrBlacklistChars: string;
|
||
ocrBrightnessBoost: number;
|
||
ocrContrastMultiplier: number;
|
||
ocrNoiseReductionLevel: number;
|
||
ocrSharpeningStrength: number;
|
||
ocrMorphologicalOperations: boolean;
|
||
ocrAdaptiveThresholdWindowSize: number;
|
||
ocrHistogramEqualization: boolean;
|
||
ocrUpscaleFactor: number;
|
||
ocrMaxImageWidth: number;
|
||
ocrMaxImageHeight: number;
|
||
saveProcessedImages: boolean;
|
||
ocrQualityThresholdBrightness: number;
|
||
ocrQualityThresholdContrast: number;
|
||
ocrQualityThresholdNoise: number;
|
||
ocrQualityThresholdSharpness: number;
|
||
ocrSkipEnhancement: boolean;
|
||
}
|
||
|
||
interface SnackbarState {
|
||
open: boolean;
|
||
message: string;
|
||
severity: 'success' | 'error' | 'warning' | 'info';
|
||
}
|
||
|
||
interface UserDialogState {
|
||
open: boolean;
|
||
mode: 'create' | 'edit';
|
||
user: User | null;
|
||
}
|
||
|
||
interface UserFormData {
|
||
username: string;
|
||
email: string;
|
||
password: string;
|
||
}
|
||
|
||
interface OcrLanguage {
|
||
code: string;
|
||
name: string;
|
||
}
|
||
|
||
interface WebDAVFolderInfo {
|
||
path: string;
|
||
total_files: number;
|
||
supported_files: number;
|
||
estimated_time_hours: number;
|
||
total_size_mb: number;
|
||
}
|
||
|
||
interface WebDAVCrawlEstimate {
|
||
folders: WebDAVFolderInfo[];
|
||
total_files: number;
|
||
total_supported_files: number;
|
||
total_estimated_time_hours: number;
|
||
total_size_mb: number;
|
||
}
|
||
|
||
interface WebDAVConnectionResult {
|
||
success: boolean;
|
||
message: string;
|
||
server_version?: string;
|
||
server_type?: string;
|
||
}
|
||
|
||
|
||
// Debounce utility function
|
||
function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number): T {
|
||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||
|
||
const debouncedFunc = useCallback((...args: Parameters<T>) => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
}
|
||
timeoutRef.current = setTimeout(() => func(...args), delay);
|
||
}, [func, delay]) as T;
|
||
|
||
// Cleanup on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
return debouncedFunc;
|
||
}
|
||
|
||
|
||
const SettingsPage: React.FC = () => {
|
||
const { user: currentUser } = useAuth();
|
||
const [tabValue, setTabValue] = useState<number>(0);
|
||
const [settings, setSettings] = useState<Settings>({
|
||
ocrLanguage: 'eng',
|
||
concurrentOcrJobs: 4,
|
||
ocrTimeoutSeconds: 300,
|
||
maxFileSizeMb: 50,
|
||
allowedFileTypes: ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
|
||
autoRotateImages: true,
|
||
enableImagePreprocessing: false,
|
||
searchResultsPerPage: 25,
|
||
searchSnippetLength: 200,
|
||
fuzzySearchThreshold: 0.8,
|
||
retentionDays: null,
|
||
enableAutoCleanup: false,
|
||
enableCompression: false,
|
||
memoryLimitMb: 512,
|
||
cpuPriority: 'normal',
|
||
enableBackgroundOcr: true,
|
||
ocrPageSegmentationMode: 3,
|
||
ocrEngineMode: 3,
|
||
ocrMinConfidence: 30.0,
|
||
ocrDpi: 300,
|
||
ocrEnhanceContrast: true,
|
||
ocrRemoveNoise: true,
|
||
ocrDetectOrientation: true,
|
||
ocrWhitelistChars: '',
|
||
ocrBlacklistChars: '',
|
||
ocrBrightnessBoost: 0.0,
|
||
ocrContrastMultiplier: 1.0,
|
||
ocrNoiseReductionLevel: 1,
|
||
ocrSharpeningStrength: 0.0,
|
||
ocrMorphologicalOperations: true,
|
||
ocrAdaptiveThresholdWindowSize: 15,
|
||
ocrHistogramEqualization: false,
|
||
ocrUpscaleFactor: 1.0,
|
||
ocrMaxImageWidth: 10000,
|
||
ocrMaxImageHeight: 10000,
|
||
saveProcessedImages: false,
|
||
ocrQualityThresholdBrightness: 40.0,
|
||
ocrQualityThresholdContrast: 0.15,
|
||
ocrQualityThresholdNoise: 0.3,
|
||
ocrQualityThresholdSharpness: 0.15,
|
||
ocrSkipEnhancement: false,
|
||
});
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [snackbar, setSnackbar] = useState<SnackbarState>({
|
||
open: false,
|
||
message: '',
|
||
severity: 'success'
|
||
});
|
||
const [userDialog, setUserDialog] = useState<UserDialogState>({
|
||
open: false,
|
||
mode: 'create',
|
||
user: null
|
||
});
|
||
const [userForm, setUserForm] = useState<UserFormData>({
|
||
username: '',
|
||
email: '',
|
||
password: ''
|
||
});
|
||
|
||
// OCR Admin Controls State
|
||
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: 'paused' | 'running' } | null>(null);
|
||
const [ocrActionLoading, setOcrActionLoading] = useState(false);
|
||
|
||
const ocrLanguages: OcrLanguage[] = [
|
||
{ code: 'eng', name: 'English' },
|
||
{ code: 'spa', name: 'Spanish' },
|
||
{ code: 'fra', name: 'French' },
|
||
{ code: 'deu', name: 'German' },
|
||
{ code: 'ita', name: 'Italian' },
|
||
{ code: 'por', name: 'Portuguese' },
|
||
{ code: 'rus', name: 'Russian' },
|
||
{ code: 'jpn', name: 'Japanese' },
|
||
{ code: 'chi_sim', name: 'Chinese (Simplified)' },
|
||
{ code: 'chi_tra', name: 'Chinese (Traditional)' },
|
||
{ code: 'kor', name: 'Korean' },
|
||
{ code: 'ara', name: 'Arabic' },
|
||
{ code: 'hin', name: 'Hindi' },
|
||
{ code: 'nld', name: 'Dutch' },
|
||
{ code: 'pol', name: 'Polish' },
|
||
];
|
||
|
||
useEffect(() => {
|
||
fetchSettings();
|
||
fetchUsers();
|
||
fetchOcrStatus();
|
||
}, []);
|
||
|
||
const fetchSettings = async (): Promise<void> => {
|
||
try {
|
||
const response = await api.get('/settings');
|
||
setSettings({
|
||
ocrLanguage: response.data.ocr_language || 'eng',
|
||
concurrentOcrJobs: response.data.concurrent_ocr_jobs || 4,
|
||
ocrTimeoutSeconds: response.data.ocr_timeout_seconds || 300,
|
||
maxFileSizeMb: response.data.max_file_size_mb || 50,
|
||
allowedFileTypes: response.data.allowed_file_types || ['pdf', 'png', 'jpg', 'jpeg', 'tiff', 'bmp', 'txt'],
|
||
autoRotateImages: response.data.auto_rotate_images !== undefined ? response.data.auto_rotate_images : true,
|
||
enableImagePreprocessing: response.data.enable_image_preprocessing !== undefined ? response.data.enable_image_preprocessing : false,
|
||
searchResultsPerPage: response.data.search_results_per_page || 25,
|
||
searchSnippetLength: response.data.search_snippet_length || 200,
|
||
fuzzySearchThreshold: response.data.fuzzy_search_threshold || 0.8,
|
||
retentionDays: response.data.retention_days,
|
||
enableAutoCleanup: response.data.enable_auto_cleanup || false,
|
||
enableCompression: response.data.enable_compression || false,
|
||
memoryLimitMb: response.data.memory_limit_mb || 512,
|
||
cpuPriority: response.data.cpu_priority || 'normal',
|
||
enableBackgroundOcr: response.data.enable_background_ocr !== undefined ? response.data.enable_background_ocr : true,
|
||
ocrPageSegmentationMode: response.data.ocr_page_segmentation_mode || 3,
|
||
ocrEngineMode: response.data.ocr_engine_mode || 3,
|
||
ocrMinConfidence: response.data.ocr_min_confidence || 30.0,
|
||
ocrDpi: response.data.ocr_dpi || 300,
|
||
ocrEnhanceContrast: response.data.ocr_enhance_contrast !== undefined ? response.data.ocr_enhance_contrast : true,
|
||
ocrRemoveNoise: response.data.ocr_remove_noise !== undefined ? response.data.ocr_remove_noise : true,
|
||
ocrDetectOrientation: response.data.ocr_detect_orientation !== undefined ? response.data.ocr_detect_orientation : true,
|
||
ocrWhitelistChars: response.data.ocr_whitelist_chars || '',
|
||
ocrBlacklistChars: response.data.ocr_blacklist_chars || '',
|
||
ocrBrightnessBoost: response.data.ocr_brightness_boost || 0.0,
|
||
ocrContrastMultiplier: response.data.ocr_contrast_multiplier || 1.0,
|
||
ocrNoiseReductionLevel: response.data.ocr_noise_reduction_level || 1,
|
||
ocrSharpeningStrength: response.data.ocr_sharpening_strength || 0.0,
|
||
ocrMorphologicalOperations: response.data.ocr_morphological_operations !== undefined ? response.data.ocr_morphological_operations : true,
|
||
ocrAdaptiveThresholdWindowSize: response.data.ocr_adaptive_threshold_window_size || 15,
|
||
ocrHistogramEqualization: response.data.ocr_histogram_equalization || false,
|
||
ocrUpscaleFactor: response.data.ocr_upscale_factor || 1.0,
|
||
ocrMaxImageWidth: response.data.ocr_max_image_width || 10000,
|
||
ocrMaxImageHeight: response.data.ocr_max_image_height || 10000,
|
||
saveProcessedImages: response.data.save_processed_images || false,
|
||
ocrQualityThresholdBrightness: response.data.ocr_quality_threshold_brightness || 40.0,
|
||
ocrQualityThresholdContrast: response.data.ocr_quality_threshold_contrast || 0.15,
|
||
ocrQualityThresholdNoise: response.data.ocr_quality_threshold_noise || 0.3,
|
||
ocrQualityThresholdSharpness: response.data.ocr_quality_threshold_sharpness || 0.15,
|
||
ocrSkipEnhancement: response.data.ocr_skip_enhancement || false,
|
||
});
|
||
} catch (error: any) {
|
||
console.error('Error fetching settings:', error);
|
||
if (error.response?.status !== 404) {
|
||
showSnackbar('Failed to load settings', 'error');
|
||
}
|
||
}
|
||
};
|
||
|
||
const fetchUsers = async (): Promise<void> => {
|
||
try {
|
||
const response = await api.get<User[]>('/users');
|
||
setUsers(response.data);
|
||
} catch (error: any) {
|
||
console.error('Error fetching users:', error);
|
||
if (error.response?.status !== 404) {
|
||
showSnackbar('Failed to load users', 'error');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSettingsChange = async (key: keyof Settings, value: any): Promise<void> => {
|
||
try {
|
||
// Convert camelCase to snake_case for API
|
||
const snakeCase = (str: string): string => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||
const apiKey = snakeCase(key);
|
||
|
||
// Build the update payload with only the changed field
|
||
const updatePayload = { [apiKey]: value };
|
||
|
||
await api.put('/settings', updatePayload);
|
||
|
||
// Only update state after successful API call
|
||
setSettings(prevSettings => ({ ...prevSettings, [key]: value }));
|
||
|
||
// Only show success message for non-text inputs to reduce noise
|
||
if (typeof value !== 'string') {
|
||
showSnackbar('Settings updated successfully', 'success');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating settings:', error);
|
||
showSnackbar('Failed to update settings', 'error');
|
||
}
|
||
};
|
||
|
||
const handleUserSubmit = async (): Promise<void> => {
|
||
setLoading(true);
|
||
try {
|
||
if (userDialog.mode === 'create') {
|
||
await api.post('/users', userForm);
|
||
showSnackbar('User created successfully', 'success');
|
||
} else {
|
||
const { password, ...updateData } = userForm;
|
||
const payload: any = updateData;
|
||
if (password) {
|
||
payload.password = password;
|
||
}
|
||
await api.put(`/users/${userDialog.user?.id}`, payload);
|
||
showSnackbar('User updated successfully', 'success');
|
||
}
|
||
fetchUsers();
|
||
handleCloseUserDialog();
|
||
} catch (error: any) {
|
||
console.error('Error saving user:', error);
|
||
showSnackbar(error.response?.data?.message || 'Failed to save user', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteUser = async (userId: string): Promise<void> => {
|
||
if (userId === currentUser?.id) {
|
||
showSnackbar('You cannot delete your own account', 'error');
|
||
return;
|
||
}
|
||
|
||
if (window.confirm('Are you sure you want to delete this user?')) {
|
||
setLoading(true);
|
||
try {
|
||
await api.delete(`/users/${userId}`);
|
||
showSnackbar('User deleted successfully', 'success');
|
||
fetchUsers();
|
||
} catch (error) {
|
||
console.error('Error deleting user:', error);
|
||
showSnackbar('Failed to delete user', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleOpenUserDialog = (mode: 'create' | 'edit', user: User | null = null): void => {
|
||
setUserDialog({ open: true, mode, user });
|
||
if (mode === 'edit' && user) {
|
||
setUserForm({ username: user.username, email: user.email, password: '' });
|
||
} else {
|
||
setUserForm({ username: '', email: '', password: '' });
|
||
}
|
||
};
|
||
|
||
const handleCloseUserDialog = (): void => {
|
||
setUserDialog({ open: false, mode: 'create', user: null });
|
||
setUserForm({ username: '', email: '', password: '' });
|
||
};
|
||
|
||
const showSnackbar = (message: string, severity: SnackbarState['severity']): void => {
|
||
setSnackbar({ open: true, message, severity });
|
||
};
|
||
|
||
const handleTabChange = (event: React.SyntheticEvent, newValue: number): void => {
|
||
setTabValue(newValue);
|
||
};
|
||
|
||
const handleOcrLanguageChange = (event: SelectChangeEvent<string>): void => {
|
||
handleSettingsChange('ocrLanguage', event.target.value);
|
||
};
|
||
|
||
const handleCpuPriorityChange = (event: SelectChangeEvent<string>): void => {
|
||
handleSettingsChange('cpuPriority', event.target.value);
|
||
};
|
||
|
||
const handleResultsPerPageChange = (event: SelectChangeEvent<number>): void => {
|
||
handleSettingsChange('searchResultsPerPage', event.target.value);
|
||
};
|
||
|
||
const fetchOcrStatus = async (): Promise<void> => {
|
||
try {
|
||
const response = await queueService.getOcrStatus();
|
||
setOcrStatus(response.data);
|
||
} catch (error: any) {
|
||
console.error('Error fetching OCR status:', error);
|
||
// Don't show error for OCR status since it might not be available for non-admin users
|
||
}
|
||
};
|
||
|
||
const handlePauseOcr = async (): Promise<void> => {
|
||
setOcrActionLoading(true);
|
||
try {
|
||
await queueService.pauseOcr();
|
||
showSnackbar('OCR processing paused successfully', 'success');
|
||
fetchOcrStatus(); // Refresh status
|
||
} catch (error: any) {
|
||
console.error('Error pausing OCR:', error);
|
||
if (error.response?.status === 403) {
|
||
showSnackbar('Admin access required to pause OCR processing', 'error');
|
||
} else {
|
||
showSnackbar('Failed to pause OCR processing', 'error');
|
||
}
|
||
} finally {
|
||
setOcrActionLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResumeOcr = async (): Promise<void> => {
|
||
setOcrActionLoading(true);
|
||
try {
|
||
await queueService.resumeOcr();
|
||
showSnackbar('OCR processing resumed successfully', 'success');
|
||
fetchOcrStatus(); // Refresh status
|
||
} catch (error: any) {
|
||
console.error('Error resuming OCR:', error);
|
||
if (error.response?.status === 403) {
|
||
showSnackbar('Admin access required to resume OCR processing', 'error');
|
||
} else {
|
||
showSnackbar('Failed to resume OCR processing', 'error');
|
||
}
|
||
} finally {
|
||
setOcrActionLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||
<Typography variant="h4" sx={{ mb: 4 }}>
|
||
Settings
|
||
</Typography>
|
||
|
||
<Paper sx={{ width: '100%' }}>
|
||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="settings tabs">
|
||
<Tab label="General" />
|
||
<Tab label="OCR Settings" />
|
||
<Tab label="User Management" />
|
||
</Tabs>
|
||
|
||
<Box sx={{ p: 3 }}>
|
||
{tabValue === 0 && (
|
||
<Box>
|
||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||
General Settings
|
||
</Typography>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
OCR Configuration
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControl fullWidth>
|
||
<InputLabel>OCR Language</InputLabel>
|
||
<Select
|
||
value={settings.ocrLanguage}
|
||
label="OCR Language"
|
||
onChange={handleOcrLanguageChange}
|
||
disabled={loading}
|
||
>
|
||
{ocrLanguages.map((lang) => (
|
||
<MenuItem key={lang.code} value={lang.code}>
|
||
{lang.name}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Concurrent OCR Jobs"
|
||
value={settings.concurrentOcrJobs}
|
||
onChange={(e) => handleSettingsChange('concurrentOcrJobs', parseInt(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 1, max: 16 }}
|
||
helperText="Number of OCR jobs that can run simultaneously"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="OCR Timeout (seconds)"
|
||
value={settings.ocrTimeoutSeconds}
|
||
onChange={(e) => handleSettingsChange('ocrTimeoutSeconds', parseInt(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 30, max: 3600 }}
|
||
helperText="Maximum time for OCR processing per file"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControl fullWidth>
|
||
<InputLabel>CPU Priority</InputLabel>
|
||
<Select
|
||
value={settings.cpuPriority}
|
||
label="CPU Priority"
|
||
onChange={handleCpuPriorityChange}
|
||
disabled={loading}
|
||
>
|
||
<MenuItem value="low">Low</MenuItem>
|
||
<MenuItem value="normal">Normal</MenuItem>
|
||
<MenuItem value="high">High</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Admin OCR Controls */}
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
<StopIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||
OCR Processing Controls (Admin Only)
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
|
||
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
||
Control OCR processing to manage CPU usage and allow users to use the application without performance impact.
|
||
</Typography>
|
||
|
||
<Grid container spacing={2} alignItems="center">
|
||
<Grid item xs={12} md={6}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||
<Button
|
||
variant={ocrStatus?.is_paused ? "outlined" : "contained"}
|
||
color={ocrStatus?.is_paused ? "success" : "warning"}
|
||
startIcon={ocrActionLoading ? <CircularProgress size={16} /> :
|
||
(ocrStatus?.is_paused ? <PlayArrowIcon /> : <PauseIcon />)}
|
||
onClick={ocrStatus?.is_paused ? handleResumeOcr : handlePauseOcr}
|
||
disabled={ocrActionLoading || loading}
|
||
size="large"
|
||
>
|
||
{ocrActionLoading ? 'Processing...' :
|
||
ocrStatus?.is_paused ? 'Resume OCR Processing' : 'Pause OCR Processing'}
|
||
</Button>
|
||
</Box>
|
||
</Grid>
|
||
|
||
<Grid item xs={12} md={6}>
|
||
{ocrStatus && (
|
||
<Box>
|
||
<Chip
|
||
label={`OCR Status: ${ocrStatus.status.toUpperCase()}`}
|
||
color={ocrStatus.is_paused ? "warning" : "success"}
|
||
variant="outlined"
|
||
icon={ocrStatus.is_paused ? <PauseIcon /> : <PlayArrowIcon />}
|
||
size="medium"
|
||
/>
|
||
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'text.secondary' }}>
|
||
{ocrStatus.is_paused
|
||
? 'OCR processing is paused. No new jobs will be processed.'
|
||
: 'OCR processing is active. Documents will be processed automatically.'}
|
||
</Typography>
|
||
</Box>
|
||
)}
|
||
</Grid>
|
||
</Grid>
|
||
|
||
{ocrStatus?.is_paused && (
|
||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||
<Typography variant="body2">
|
||
<strong>OCR Processing Paused</strong><br />
|
||
New documents will not be processed for OCR text extraction until processing is resumed.
|
||
Users can still upload and view documents, but search functionality may be limited.
|
||
</Typography>
|
||
</Alert>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
File Processing
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Max File Size (MB)"
|
||
value={settings.maxFileSizeMb}
|
||
onChange={(e) => handleSettingsChange('maxFileSizeMb', parseInt(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 1, max: 500 }}
|
||
helperText="Maximum allowed file size for uploads"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Memory Limit (MB)"
|
||
value={settings.memoryLimitMb}
|
||
onChange={(e) => handleSettingsChange('memoryLimitMb', parseInt(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 128, max: 4096 }}
|
||
helperText="Memory limit per OCR job"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<FormControl sx={{ mb: 2 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.autoRotateImages}
|
||
onChange={(e) => handleSettingsChange('autoRotateImages', e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Auto-rotate Images"
|
||
/>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Automatically detect and correct image orientation
|
||
</Typography>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<FormControl sx={{ mb: 2 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.enableImagePreprocessing}
|
||
onChange={(e) => handleSettingsChange('enableImagePreprocessing', e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Enable Image Preprocessing"
|
||
/>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Enhance images for better OCR accuracy (deskew, denoise, contrast)
|
||
</Typography>
|
||
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
|
||
⚠️ Warning: Enabling preprocessing can significantly alter OCR text results and may reduce accuracy for some documents
|
||
</Typography>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<FormControl sx={{ mb: 2 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.enableBackgroundOcr}
|
||
onChange={(e) => handleSettingsChange('enableBackgroundOcr', e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Enable Background OCR"
|
||
/>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Process OCR in the background after file upload
|
||
</Typography>
|
||
</FormControl>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Search Configuration
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControl fullWidth>
|
||
<InputLabel>Results Per Page</InputLabel>
|
||
<Select
|
||
value={settings.searchResultsPerPage}
|
||
label="Results Per Page"
|
||
onChange={handleResultsPerPageChange}
|
||
disabled={loading}
|
||
>
|
||
<MenuItem value={10}>10</MenuItem>
|
||
<MenuItem value={25}>25</MenuItem>
|
||
<MenuItem value={50}>50</MenuItem>
|
||
<MenuItem value={100}>100</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Snippet Length"
|
||
value={settings.searchSnippetLength}
|
||
onChange={(e) => handleSettingsChange('searchSnippetLength', parseInt(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 50, max: 500 }}
|
||
helperText="Characters to show in search result previews"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Fuzzy Search Threshold"
|
||
value={settings.fuzzySearchThreshold}
|
||
onChange={(e) => handleSettingsChange('fuzzySearchThreshold', parseFloat(e.target.value))}
|
||
disabled={loading}
|
||
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||
helperText="Tolerance for spelling mistakes (0.0-1.0)"
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Storage Management
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
type="number"
|
||
label="Retention Days"
|
||
value={settings.retentionDays || ''}
|
||
onChange={(e) => handleSettingsChange('retentionDays', e.target.value ? parseInt(e.target.value) : null)}
|
||
disabled={loading}
|
||
inputProps={{ min: 1 }}
|
||
helperText="Auto-delete documents after X days (leave empty to disable)"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<FormControl sx={{ mb: 2 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.enableAutoCleanup}
|
||
onChange={(e) => handleSettingsChange('enableAutoCleanup', e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Enable Auto Cleanup"
|
||
/>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Automatically remove orphaned files and clean up storage
|
||
</Typography>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<FormControl sx={{ mb: 2 }}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.enableCompression}
|
||
onChange={(e) => handleSettingsChange('enableCompression', e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Enable Compression"
|
||
/>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Compress stored documents to save disk space
|
||
</Typography>
|
||
</FormControl>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
</Box>
|
||
)}
|
||
|
||
{tabValue === 1 && (
|
||
<Box>
|
||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||
OCR Image Processing Settings
|
||
</Typography>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Enhancement Controls
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.ocrSkipEnhancement}
|
||
onChange={(e) => handleSettingsChange('ocrSkipEnhancement', e.target.checked)}
|
||
/>
|
||
}
|
||
label="Skip All Image Enhancement (Use Original Images Only)"
|
||
sx={{ mb: 2 }}
|
||
/>
|
||
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Brightness Boost"
|
||
type="number"
|
||
value={settings.ocrBrightnessBoost}
|
||
onChange={(e) => handleSettingsChange('ocrBrightnessBoost', parseFloat(e.target.value) || 0)}
|
||
helperText="Manual brightness adjustment (0 = auto, >0 = boost amount)"
|
||
inputProps={{ step: 0.1, min: 0, max: 100 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Contrast Multiplier"
|
||
type="number"
|
||
value={settings.ocrContrastMultiplier}
|
||
onChange={(e) => handleSettingsChange('ocrContrastMultiplier', parseFloat(e.target.value) || 1)}
|
||
helperText="Manual contrast adjustment (1.0 = auto, >1.0 = increase)"
|
||
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControl fullWidth>
|
||
<InputLabel>Noise Reduction Level</InputLabel>
|
||
<Select
|
||
value={settings.ocrNoiseReductionLevel}
|
||
label="Noise Reduction Level"
|
||
onChange={(e) => handleSettingsChange('ocrNoiseReductionLevel', e.target.value as number)}
|
||
>
|
||
<MenuItem value={0}>None</MenuItem>
|
||
<MenuItem value={1}>Light</MenuItem>
|
||
<MenuItem value={2}>Moderate</MenuItem>
|
||
<MenuItem value={3}>Heavy</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Sharpening Strength"
|
||
type="number"
|
||
value={settings.ocrSharpeningStrength}
|
||
onChange={(e) => handleSettingsChange('ocrSharpeningStrength', parseFloat(e.target.value) || 0)}
|
||
helperText="Image sharpening amount (0 = auto, >0 = manual)"
|
||
inputProps={{ step: 0.1, min: 0, max: 2 }}
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Quality Thresholds (when to apply enhancements)
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Brightness Threshold"
|
||
type="number"
|
||
value={settings.ocrQualityThresholdBrightness}
|
||
onChange={(e) => handleSettingsChange('ocrQualityThresholdBrightness', parseFloat(e.target.value) || 40)}
|
||
helperText="Enhance if brightness below this value (0-255)"
|
||
inputProps={{ step: 1, min: 0, max: 255 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Contrast Threshold"
|
||
type="number"
|
||
value={settings.ocrQualityThresholdContrast}
|
||
onChange={(e) => handleSettingsChange('ocrQualityThresholdContrast', parseFloat(e.target.value) || 0.15)}
|
||
helperText="Enhance if contrast below this value (0-1)"
|
||
inputProps={{ step: 0.01, min: 0, max: 1 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Noise Threshold"
|
||
type="number"
|
||
value={settings.ocrQualityThresholdNoise}
|
||
onChange={(e) => handleSettingsChange('ocrQualityThresholdNoise', parseFloat(e.target.value) || 0.3)}
|
||
helperText="Enhance if noise above this value (0-1)"
|
||
inputProps={{ step: 0.01, min: 0, max: 1 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Sharpness Threshold"
|
||
type="number"
|
||
value={settings.ocrQualityThresholdSharpness}
|
||
onChange={(e) => handleSettingsChange('ocrQualityThresholdSharpness', parseFloat(e.target.value) || 0.15)}
|
||
helperText="Enhance if sharpness below this value (0-1)"
|
||
inputProps={{ step: 0.01, min: 0, max: 1 }}
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Advanced Processing Options
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.ocrMorphologicalOperations}
|
||
onChange={(e) => handleSettingsChange('ocrMorphologicalOperations', e.target.checked)}
|
||
/>
|
||
}
|
||
label="Morphological Operations (text cleanup)"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.ocrHistogramEqualization}
|
||
onChange={(e) => handleSettingsChange('ocrHistogramEqualization', e.target.checked)}
|
||
/>
|
||
}
|
||
label="Histogram Equalization"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={settings.saveProcessedImages}
|
||
onChange={(e) => handleSettingsChange('saveProcessedImages', e.target.checked)}
|
||
/>
|
||
}
|
||
label="Save Processed Images for Review"
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Adaptive Threshold Window Size"
|
||
type="number"
|
||
value={settings.ocrAdaptiveThresholdWindowSize}
|
||
onChange={(e) => handleSettingsChange('ocrAdaptiveThresholdWindowSize', parseInt(e.target.value) || 15)}
|
||
helperText="Window size for contrast enhancement (odd number)"
|
||
inputProps={{ step: 2, min: 3, max: 101 }}
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||
Image Size and Scaling
|
||
</Typography>
|
||
<Divider sx={{ mb: 2 }} />
|
||
|
||
<Grid container spacing={2}>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Max Image Width"
|
||
type="number"
|
||
value={settings.ocrMaxImageWidth}
|
||
onChange={(e) => handleSettingsChange('ocrMaxImageWidth', parseInt(e.target.value) || 10000)}
|
||
helperText="Maximum image width in pixels"
|
||
inputProps={{ step: 100, min: 100, max: 50000 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Max Image Height"
|
||
type="number"
|
||
value={settings.ocrMaxImageHeight}
|
||
onChange={(e) => handleSettingsChange('ocrMaxImageHeight', parseInt(e.target.value) || 10000)}
|
||
helperText="Maximum image height in pixels"
|
||
inputProps={{ step: 100, min: 100, max: 50000 }}
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={6}>
|
||
<TextField
|
||
fullWidth
|
||
label="Upscale Factor"
|
||
type="number"
|
||
value={settings.ocrUpscaleFactor}
|
||
onChange={(e) => handleSettingsChange('ocrUpscaleFactor', parseFloat(e.target.value) || 1.0)}
|
||
helperText="Image scaling factor (1.0 = no scaling)"
|
||
inputProps={{ step: 0.1, min: 0.1, max: 5 }}
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
</Box>
|
||
)}
|
||
|
||
{tabValue === 2 && (
|
||
<Box>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||
<Typography variant="h6">
|
||
User Management
|
||
</Typography>
|
||
<Button
|
||
variant="contained"
|
||
startIcon={<AddIcon />}
|
||
onClick={() => handleOpenUserDialog('create')}
|
||
disabled={loading}
|
||
>
|
||
Add User
|
||
</Button>
|
||
</Box>
|
||
|
||
<TableContainer component={Paper}>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableCell>Username</TableCell>
|
||
<TableCell>Email</TableCell>
|
||
<TableCell>Created At</TableCell>
|
||
<TableCell align="right">Actions</TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{users.map((user) => (
|
||
<TableRow key={user.id}>
|
||
<TableCell>{user.username}</TableCell>
|
||
<TableCell>{user.email}</TableCell>
|
||
<TableCell>{new Date(user.created_at).toLocaleDateString()}</TableCell>
|
||
<TableCell align="right">
|
||
<IconButton
|
||
onClick={() => handleOpenUserDialog('edit', user)}
|
||
disabled={loading}
|
||
>
|
||
<EditIcon />
|
||
</IconButton>
|
||
<IconButton
|
||
onClick={() => handleDeleteUser(user.id)}
|
||
disabled={loading || user.id === currentUser?.id}
|
||
>
|
||
<DeleteIcon />
|
||
</IconButton>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Paper>
|
||
|
||
<Dialog open={userDialog.open} onClose={handleCloseUserDialog} maxWidth="sm" fullWidth>
|
||
<DialogTitle>
|
||
{userDialog.mode === 'create' ? 'Create New User' : 'Edit User'}
|
||
</DialogTitle>
|
||
<DialogContent>
|
||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label="Username"
|
||
value={userForm.username}
|
||
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
|
||
required
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label="Email"
|
||
type="email"
|
||
value={userForm.email}
|
||
onChange={(e) => setUserForm({ ...userForm, email: e.target.value })}
|
||
required
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<TextField
|
||
fullWidth
|
||
label={userDialog.mode === 'create' ? 'Password' : 'New Password (leave empty to keep current)'}
|
||
type="password"
|
||
value={userForm.password}
|
||
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
|
||
required={userDialog.mode === 'create'}
|
||
/>
|
||
</Grid>
|
||
</Grid>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleCloseUserDialog} disabled={loading}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={handleUserSubmit} variant="contained" disabled={loading}>
|
||
{userDialog.mode === 'create' ? 'Create' : 'Update'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
<Snackbar
|
||
open={snackbar.open}
|
||
autoHideDuration={6000}
|
||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||
>
|
||
<Alert
|
||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||
severity={snackbar.severity}
|
||
sx={{ width: '100%' }}
|
||
>
|
||
{snackbar.message}
|
||
</Alert>
|
||
</Snackbar>
|
||
</Container>
|
||
);
|
||
};
|
||
|
||
export default SettingsPage; |