feat(webdav): support capturing individual directory errors in webdav
This commit is contained in:
parent
67ae68745c
commit
93c2863d01
|
|
@ -12,7 +12,7 @@ You are an elite Rust systems engineer with deep expertise in OCR technologies,
|
|||
You possess mastery in:
|
||||
- **Rust Development**: Advanced knowledge of Rust's ownership system, lifetimes, trait systems, async/await patterns, and zero-cost abstractions
|
||||
- **OCR Technologies**: Experience with Tesseract, OpenCV, and Rust OCR libraries; understanding of image preprocessing, text extraction pipelines, and accuracy optimization
|
||||
- **Concurrency & Parallelism**: Expert use of tokio, async-std, rayon, crossbeam; designing lock-free data structures, managing thread pools, and preventing race conditions
|
||||
- **Concurrency & Parallelism**: Expert use of tokio, async-std, rayon, crossbeam; managing thread pools, and preventing race conditions
|
||||
- **Storage Systems**: Deep understanding of WebDAV protocol implementation, AWS S3 SDK usage, filesystem abstractions, and cross-platform file handling
|
||||
- **Synchronization Algorithms**: Implementing efficient diff algorithms, conflict resolution strategies, eventual consistency models, and bidirectional sync patterns
|
||||
- **API Design**: RESTful and gRPC API implementation, rate limiting, authentication, versioning, and error handling strategies
|
||||
|
|
|
|||
|
|
@ -0,0 +1,619 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Divider,
|
||||
Chip,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Collapse,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Alert,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Block as BlockIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Speed as SpeedIcon,
|
||||
Folder as FolderIcon,
|
||||
CloudOff as CloudOffIcon,
|
||||
Timer as TimerIcon,
|
||||
Info as InfoIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
import { WebDAVScanFailure } from '../../services/api';
|
||||
import { modernTokens } from '../../theme';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
|
||||
interface FailureDetailsPanelProps {
|
||||
failure: WebDAVScanFailure;
|
||||
onRetry: (failure: WebDAVScanFailure, notes?: string) => Promise<void>;
|
||||
onExclude: (failure: WebDAVScanFailure, notes?: string, permanent?: boolean) => Promise<void>;
|
||||
isRetrying?: boolean;
|
||||
isExcluding?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (notes?: string, permanent?: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText: string;
|
||||
confirmColor?: 'primary' | 'error' | 'warning';
|
||||
showPermanentOption?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
confirmColor = 'primary',
|
||||
showPermanentOption = false,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [permanent, setPermanent] = useState(true);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(notes || undefined, showPermanentOption ? permanent : undefined);
|
||||
setNotes('');
|
||||
setPermanent(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setNotes('');
|
||||
setPermanent(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Notes (optional)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
multiline
|
||||
rows={3}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{showPermanentOption && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={permanent}
|
||||
onChange={(e) => setPermanent(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Permanently exclude (recommended)"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
color={confirmColor}
|
||||
variant="contained"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const FailureDetailsPanel: React.FC<FailureDetailsPanelProps> = ({
|
||||
failure,
|
||||
onRetry,
|
||||
onExclude,
|
||||
isRetrying = false,
|
||||
isExcluding = false,
|
||||
}) => {
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [retryDialogOpen, setRetryDialogOpen] = useState(false);
|
||||
const [excludeDialogOpen, setExcludeDialogOpen] = useState(false);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
// Handle copy to clipboard
|
||||
const handleCopy = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: `${label} copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to copy ${label}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Format bytes
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes) return 'N/A';
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// Format duration
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return 'N/A';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
};
|
||||
|
||||
// Get recommendation color and icon
|
||||
const getRecommendationStyle = () => {
|
||||
if (failure.diagnostic_summary.user_action_required) {
|
||||
return {
|
||||
color: modernTokens.colors.warning[600],
|
||||
bgColor: modernTokens.colors.warning[50],
|
||||
icon: WarningIcon,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: modernTokens.colors.info[600],
|
||||
bgColor: modernTokens.colors.info[50],
|
||||
icon: InfoIcon,
|
||||
};
|
||||
};
|
||||
|
||||
const recommendationStyle = getRecommendationStyle();
|
||||
const RecommendationIcon = recommendationStyle.icon;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Error Message */}
|
||||
{failure.error_message && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
action={
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(failure.error_message!, 'Error message')}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
{failure.error_message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: '100%',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 2,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
}}
|
||||
>
|
||||
Directory Information
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Path
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
wordBreak: 'break-all',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{failure.directory_path}
|
||||
</Typography>
|
||||
<Tooltip title="Copy path">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(failure.directory_path, 'Directory path')}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Failure Count
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{failure.failure_count} total • {failure.consecutive_failures} consecutive
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Timeline
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
<strong>First failure:</strong> {new Date(failure.first_failure_at).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
<strong>Last failure:</strong> {new Date(failure.last_failure_at).toLocaleString()}
|
||||
</Typography>
|
||||
{failure.next_retry_at && (
|
||||
<Typography variant="body2">
|
||||
<strong>Next retry:</strong> {new Date(failure.next_retry_at).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{failure.http_status_code && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
HTTP Status
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${failure.http_status_code}`}
|
||||
size="small"
|
||||
color={failure.http_status_code < 400 ? 'success' : 'error'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: '100%',
|
||||
backgroundColor: recommendationStyle.bgColor,
|
||||
border: `1px solid ${recommendationStyle.color}20`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<RecommendationIcon
|
||||
sx={{
|
||||
color: recommendationStyle.color,
|
||||
fontSize: 20,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: recommendationStyle.color,
|
||||
}}
|
||||
>
|
||||
Recommended Action
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[800],
|
||||
lineHeight: 1.6,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{failure.diagnostic_summary.recommended_action}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{failure.diagnostic_summary.can_retry && (
|
||||
<Chip
|
||||
icon={<RefreshIcon />}
|
||||
label="Can retry"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.success[100],
|
||||
color: modernTokens.colors.success[700],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{failure.diagnostic_summary.user_action_required && (
|
||||
<Chip
|
||||
icon={<WarningIcon />}
|
||||
label="Action required"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.warning[100],
|
||||
color: modernTokens.colors.warning[700],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Diagnostic Information (Collapsible) */}
|
||||
<Card variant="outlined" sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => setShowDiagnostics(!showDiagnostics)}
|
||||
endIcon={showDiagnostics ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
textAlign: 'left',
|
||||
p: 0,
|
||||
textTransform: 'none',
|
||||
color: modernTokens.colors.neutral[700],
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Diagnostic Details
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
<Collapse in={showDiagnostics}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
{failure.diagnostic_summary.path_length && (
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<FolderIcon sx={{ color: modernTokens.colors.primary[500], mb: 1 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{failure.diagnostic_summary.path_length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Path Length (chars)
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{failure.diagnostic_summary.directory_depth && (
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<FolderIcon sx={{ color: modernTokens.colors.info[500], mb: 1 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{failure.diagnostic_summary.directory_depth}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Directory Depth
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{failure.diagnostic_summary.estimated_item_count && (
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<CloudOffIcon sx={{ color: modernTokens.colors.warning[500], mb: 1 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{failure.diagnostic_summary.estimated_item_count.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Estimated Items
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{failure.diagnostic_summary.response_time_ms && (
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<TimerIcon sx={{ color: modernTokens.colors.error[500], mb: 1 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{formatDuration(failure.diagnostic_summary.response_time_ms)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Response Time
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{failure.diagnostic_summary.response_size_mb && (
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<SpeedIcon sx={{ color: modernTokens.colors.secondary[500], mb: 1 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{failure.diagnostic_summary.response_size_mb.toFixed(1)} MB
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Response Size
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{failure.diagnostic_summary.server_type && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
Server Type
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
|
||||
{failure.diagnostic_summary.server_type}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Notes */}
|
||||
{failure.user_notes && (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>User Notes:</strong> {failure.user_notes}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!failure.resolved && !failure.user_excluded && (
|
||||
<Stack direction="row" spacing={2} justifyContent="flex-end">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<BlockIcon />}
|
||||
onClick={() => setExcludeDialogOpen(true)}
|
||||
disabled={isExcluding}
|
||||
color="warning"
|
||||
>
|
||||
Exclude Directory
|
||||
</Button>
|
||||
|
||||
{failure.diagnostic_summary.can_retry && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => setRetryDialogOpen(true)}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
Retry Scan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialogs */}
|
||||
<ConfirmationDialog
|
||||
open={retryDialogOpen}
|
||||
onClose={() => setRetryDialogOpen(false)}
|
||||
onConfirm={(notes) => {
|
||||
onRetry(failure, notes);
|
||||
setRetryDialogOpen(false);
|
||||
}}
|
||||
title="Retry WebDAV Scan"
|
||||
description={`This will attempt to scan "${failure.directory_path}" again. The failure will be reset and moved to the retry queue.`}
|
||||
confirmText="Retry Now"
|
||||
confirmColor="primary"
|
||||
isLoading={isRetrying}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={excludeDialogOpen}
|
||||
onClose={() => setExcludeDialogOpen(false)}
|
||||
onConfirm={(notes, permanent) => {
|
||||
onExclude(failure, notes, permanent);
|
||||
setExcludeDialogOpen(false);
|
||||
}}
|
||||
title="Exclude Directory from Scanning"
|
||||
description={`This will prevent "${failure.directory_path}" from being scanned in future synchronizations.`}
|
||||
confirmText="Exclude Directory"
|
||||
confirmColor="warning"
|
||||
showPermanentOption
|
||||
isLoading={isExcluding}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailureDetailsPanel;
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Stack,
|
||||
Chip,
|
||||
Alert,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Link,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lightbulb as LightbulbIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Folder as FolderIcon,
|
||||
Security as SecurityIcon,
|
||||
Network as NetworkIcon,
|
||||
Settings as SettingsIcon,
|
||||
Speed as SpeedIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { WebDAVScanFailure, WebDAVScanFailureType } from '../../services/api';
|
||||
import { modernTokens } from '../../theme';
|
||||
|
||||
interface RecommendationsSectionProps {
|
||||
failures: WebDAVScanFailure[];
|
||||
}
|
||||
|
||||
interface RecommendationInfo {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
actions: string[];
|
||||
learnMoreUrl?: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
const getRecommendationsForFailureType = (type: WebDAVScanFailureType): RecommendationInfo => {
|
||||
const recommendations: Record<WebDAVScanFailureType, RecommendationInfo> = {
|
||||
timeout: {
|
||||
icon: ScheduleIcon,
|
||||
title: 'Timeout Issues',
|
||||
description: 'Directories are taking too long to scan. This often indicates large directories or slow server response.',
|
||||
actions: [
|
||||
'Consider organizing files into smaller subdirectories',
|
||||
'Check your network connection speed',
|
||||
'Verify the WebDAV server performance',
|
||||
'Try scanning during off-peak hours',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#timeout-issues',
|
||||
severity: 'warning',
|
||||
},
|
||||
path_too_long: {
|
||||
icon: FolderIcon,
|
||||
title: 'Path Length Limits',
|
||||
description: 'File paths are exceeding the maximum allowed length (typically 260 characters on Windows, 4096 on Unix).',
|
||||
actions: [
|
||||
'Shorten directory and file names',
|
||||
'Reduce nesting depth of folders',
|
||||
'Move files to a shorter base path',
|
||||
'Consider using symbolic links for deep structures',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#path-length',
|
||||
severity: 'error',
|
||||
},
|
||||
permission_denied: {
|
||||
icon: SecurityIcon,
|
||||
title: 'Permission Issues',
|
||||
description: 'The WebDAV client does not have sufficient permissions to access these directories.',
|
||||
actions: [
|
||||
'Verify your WebDAV username and password',
|
||||
'Check directory permissions on the server',
|
||||
'Ensure the user has read access to all subdirectories',
|
||||
'Contact your system administrator if needed',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-setup#permissions',
|
||||
severity: 'error',
|
||||
},
|
||||
invalid_characters: {
|
||||
icon: WarningIcon,
|
||||
title: 'Invalid Characters',
|
||||
description: 'File or directory names contain characters that are not supported by the file system or WebDAV protocol.',
|
||||
actions: [
|
||||
'Remove or replace special characters in file names',
|
||||
'Avoid characters like: < > : " | ? * \\',
|
||||
'Use ASCII characters when possible',
|
||||
'Rename files with Unicode characters if causing issues',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#invalid-characters',
|
||||
severity: 'warning',
|
||||
},
|
||||
network_error: {
|
||||
icon: NetworkIcon,
|
||||
title: 'Network Connectivity',
|
||||
description: 'Unable to establish a stable connection to the WebDAV server.',
|
||||
actions: [
|
||||
'Check your internet connection',
|
||||
'Verify the WebDAV server URL is correct',
|
||||
'Test connectivity with other WebDAV clients',
|
||||
'Check firewall settings',
|
||||
'Try using a different network',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#network-issues',
|
||||
severity: 'error',
|
||||
},
|
||||
server_error: {
|
||||
icon: SettingsIcon,
|
||||
title: 'Server Issues',
|
||||
description: 'The WebDAV server returned an error. This may be temporary or indicate server configuration issues.',
|
||||
actions: [
|
||||
'Wait and retry - server issues are often temporary',
|
||||
'Check server logs for detailed error information',
|
||||
'Verify server configuration and resources',
|
||||
'Contact your WebDAV server administrator',
|
||||
'Try accessing the server with other clients',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#server-errors',
|
||||
severity: 'warning',
|
||||
},
|
||||
xml_parse_error: {
|
||||
icon: WarningIcon,
|
||||
title: 'Protocol Issues',
|
||||
description: 'Unable to parse the server response. This may indicate WebDAV protocol compatibility issues.',
|
||||
actions: [
|
||||
'Verify the server supports WebDAV protocol',
|
||||
'Check if the server returns valid XML responses',
|
||||
'Try connecting with different WebDAV client settings',
|
||||
'Update the server software if possible',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#protocol-issues',
|
||||
severity: 'warning',
|
||||
},
|
||||
too_many_items: {
|
||||
icon: SpeedIcon,
|
||||
title: 'Large Directory Optimization',
|
||||
description: 'Directories contain too many files, causing performance issues and potential timeouts.',
|
||||
actions: [
|
||||
'Organize files into multiple subdirectories',
|
||||
'Archive old files to reduce directory size',
|
||||
'Use date-based or category-based folder structures',
|
||||
'Consider excluding very large directories temporarily',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-optimization#large-directories',
|
||||
severity: 'warning',
|
||||
},
|
||||
depth_limit: {
|
||||
icon: FolderIcon,
|
||||
title: 'Directory Depth Limits',
|
||||
description: 'Directory nesting is too deep, exceeding system or protocol limits.',
|
||||
actions: [
|
||||
'Flatten the directory structure',
|
||||
'Move deeply nested files to shallower locations',
|
||||
'Reorganize the folder hierarchy',
|
||||
'Use shorter path names at each level',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#depth-limits',
|
||||
severity: 'warning',
|
||||
},
|
||||
size_limit: {
|
||||
icon: SpeedIcon,
|
||||
title: 'Size Limitations',
|
||||
description: 'Files or directories are too large for the current configuration.',
|
||||
actions: [
|
||||
'Check file size limits on the WebDAV server',
|
||||
'Split large files into smaller parts',
|
||||
'Exclude very large files from synchronization',
|
||||
'Increase server limits if possible',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#size-limits',
|
||||
severity: 'warning',
|
||||
},
|
||||
unknown: {
|
||||
icon: InfoIcon,
|
||||
title: 'Unknown Issues',
|
||||
description: 'An unclassified error occurred. This may require manual investigation.',
|
||||
actions: [
|
||||
'Check the detailed error message for clues',
|
||||
'Try the operation again later',
|
||||
'Contact support with the full error details',
|
||||
'Check server and client logs',
|
||||
],
|
||||
learnMoreUrl: '/docs/webdav-troubleshooting#general',
|
||||
severity: 'info',
|
||||
},
|
||||
};
|
||||
|
||||
return recommendations[type];
|
||||
};
|
||||
|
||||
const RecommendationsSection: React.FC<RecommendationsSectionProps> = ({ failures }) => {
|
||||
// Group failures by type and get unique types
|
||||
const failureTypeStats = failures.reduce((acc, failure) => {
|
||||
if (!failure.resolved && !failure.user_excluded) {
|
||||
acc[failure.failure_type] = (acc[failure.failure_type] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<WebDAVScanFailureType, number>);
|
||||
|
||||
const activeFailureTypes = Object.keys(failureTypeStats) as WebDAVScanFailureType[];
|
||||
|
||||
if (activeFailureTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by frequency (most common issues first)
|
||||
const sortedFailureTypes = activeFailureTypes.sort(
|
||||
(a, b) => failureTypeStats[b] - failureTypeStats[a]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.primary[50],
|
||||
border: `1px solid ${modernTokens.colors.primary[200]}`,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<LightbulbIcon sx={{ color: modernTokens.colors.primary[600], fontSize: 24 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.primary[700],
|
||||
}}
|
||||
>
|
||||
Recommendations & Solutions
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
Based on your current scan failures, here are targeted recommendations to resolve common issues:
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{sortedFailureTypes.map((failureType, index) => {
|
||||
const recommendation = getRecommendationsForFailureType(failureType);
|
||||
const Icon = recommendation.icon;
|
||||
const count = failureTypeStats[failureType];
|
||||
|
||||
return (
|
||||
<Box key={failureType}>
|
||||
{index > 0 && <Divider sx={{ my: 2 }} />}
|
||||
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.neutral[0],
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
<Icon
|
||||
sx={{
|
||||
color: recommendation.severity === 'error'
|
||||
? modernTokens.colors.error[500]
|
||||
: recommendation.severity === 'warning'
|
||||
? modernTokens.colors.warning[500]
|
||||
: modernTokens.colors.info[500],
|
||||
fontSize: 24,
|
||||
mt: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
}}
|
||||
>
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={`${count} ${count === 1 ? 'failure' : 'failures'}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: recommendation.severity === 'error'
|
||||
? modernTokens.colors.error[100]
|
||||
: recommendation.severity === 'warning'
|
||||
? modernTokens.colors.warning[100]
|
||||
: modernTokens.colors.info[100],
|
||||
color: recommendation.severity === 'error'
|
||||
? modernTokens.colors.error[700]
|
||||
: recommendation.severity === 'warning'
|
||||
? modernTokens.colors.warning[700]
|
||||
: modernTokens.colors.info[700],
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.neutral[800],
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Recommended Actions:
|
||||
</Typography>
|
||||
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{recommendation.actions.map((action, actionIndex) => (
|
||||
<ListItem key={actionIndex} sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: modernTokens.colors.primary[500],
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={action}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: { color: modernTokens.colors.neutral[700] },
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{recommendation.learnMoreUrl && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Link
|
||||
href={recommendation.learnMoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
color: modernTokens.colors.primary[600],
|
||||
textDecoration: 'none',
|
||||
fontSize: '0.875rem',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Learn more about this issue
|
||||
<ExternalLinkIcon sx={{ fontSize: 16 }} />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* General Tips */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.info[50],
|
||||
borderColor: modernTokens.colors.info[200],
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
General Troubleshooting Tips:
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
<ListItem sx={{ py: 0, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Most issues resolve automatically after addressing the underlying cause"
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ py: 0, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Use the retry function after making changes to test the fix"
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ py: 0, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Exclude problematic directories temporarily while working on solutions"
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ py: 0, px: 0 }}>
|
||||
<ListItemText
|
||||
primary="Monitor the statistics dashboard to track improvement over time"
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Alert>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendationsSection;
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Block as BlockIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { WebDAVScanFailureStats } from '../../services/api';
|
||||
import { modernTokens } from '../../theme';
|
||||
|
||||
interface StatsDashboardProps {
|
||||
stats: WebDAVScanFailureStats;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
description?: string;
|
||||
percentage?: number;
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
bgColor,
|
||||
description,
|
||||
percentage,
|
||||
}) => (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: `linear-gradient(135deg, ${bgColor} 0%, ${bgColor}88 100%)`,
|
||||
border: `1px solid ${color}20`,
|
||||
borderRadius: 3,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: modernTokens.shadows.lg,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
backgroundColor: `${color}15`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ color, fontSize: 24 }} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[500],
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{percentage !== undefined && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percentage}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: `${color}10`,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
backgroundColor: color,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[500],
|
||||
mt: 0.5,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{percentage.toFixed(1)}% of total
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const StatsDashboard: React.FC<StatsDashboardProps> = ({ stats, isLoading }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={2} key={i}>
|
||||
<Card sx={{ height: 140 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Skeleton variant="circular" width={48} height={48} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" height={32} width="60%" />
|
||||
<Skeleton variant="text" height={20} width="80%" />
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const totalFailures = stats.active_failures + stats.resolved_failures;
|
||||
const criticalPercentage = totalFailures > 0 ? (stats.critical_failures / totalFailures) * 100 : 0;
|
||||
const highPercentage = totalFailures > 0 ? (stats.high_failures / totalFailures) * 100 : 0;
|
||||
const mediumPercentage = totalFailures > 0 ? (stats.medium_failures / totalFailures) * 100 : 0;
|
||||
const lowPercentage = totalFailures > 0 ? (stats.low_failures / totalFailures) * 100 : 0;
|
||||
const retryPercentage = stats.active_failures > 0 ? (stats.ready_for_retry / stats.active_failures) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
Scan Failure Statistics
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Total Active Failures */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="Active Failures"
|
||||
value={stats.active_failures}
|
||||
icon={ErrorIcon}
|
||||
color={modernTokens.colors.error[500]}
|
||||
bgColor={modernTokens.colors.error[50]}
|
||||
description="Requiring attention"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Critical Failures */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="Critical"
|
||||
value={stats.critical_failures}
|
||||
icon={ErrorIcon}
|
||||
color={modernTokens.colors.error[600]}
|
||||
bgColor={modernTokens.colors.error[50]}
|
||||
percentage={criticalPercentage}
|
||||
description="Immediate action needed"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* High Priority Failures */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="High Priority"
|
||||
value={stats.high_failures}
|
||||
icon={WarningIcon}
|
||||
color={modernTokens.colors.warning[600]}
|
||||
bgColor={modernTokens.colors.warning[50]}
|
||||
percentage={highPercentage}
|
||||
description="Important issues"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Medium Priority Failures */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="Medium Priority"
|
||||
value={stats.medium_failures}
|
||||
icon={InfoIcon}
|
||||
color={modernTokens.colors.warning[500]}
|
||||
bgColor={modernTokens.colors.warning[50]}
|
||||
percentage={mediumPercentage}
|
||||
description="Moderate issues"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Low Priority Failures */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="Low Priority"
|
||||
value={stats.low_failures}
|
||||
icon={InfoIcon}
|
||||
color={modernTokens.colors.info[500]}
|
||||
bgColor={modernTokens.colors.info[50]}
|
||||
percentage={lowPercentage}
|
||||
description="Minor issues"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Ready for Retry */}
|
||||
<Grid item xs={12} sm={6} md={4} lg={2}>
|
||||
<StatCard
|
||||
title="Ready for Retry"
|
||||
value={stats.ready_for_retry}
|
||||
icon={RefreshIcon}
|
||||
color={modernTokens.colors.primary[500]}
|
||||
bgColor={modernTokens.colors.primary[50]}
|
||||
percentage={retryPercentage}
|
||||
description="Can be retried now"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Summary Row */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<StatCard
|
||||
title="Resolved Failures"
|
||||
value={stats.resolved_failures}
|
||||
icon={CheckCircleIcon}
|
||||
color={modernTokens.colors.success[500]}
|
||||
bgColor={modernTokens.colors.success[50]}
|
||||
description="Successfully resolved"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<StatCard
|
||||
title="Excluded Directories"
|
||||
value={stats.excluded_directories}
|
||||
icon={BlockIcon}
|
||||
color={modernTokens.colors.neutral[500]}
|
||||
bgColor={modernTokens.colors.neutral[50]}
|
||||
description="Manually excluded"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: `linear-gradient(135deg, ${modernTokens.colors.primary[50]} 0%, ${modernTokens.colors.primary[25]} 100%)`,
|
||||
border: `1px solid ${modernTokens.colors.primary[200]}`,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
}}
|
||||
>
|
||||
Success Rate
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
{totalFailures > 0 ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: modernTokens.colors.primary[600],
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{((stats.resolved_failures / totalFailures) * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(stats.resolved_failures / totalFailures) * 100}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
backgroundColor: modernTokens.colors.primary[500],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
mt: 1,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{stats.resolved_failures} of {totalFailures} failures resolved
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: modernTokens.colors.success[600],
|
||||
}}
|
||||
>
|
||||
100%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDashboard;
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Fade,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Search as SearchIcon,
|
||||
FilterList as FilterIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
import { webdavService, WebDAVScanFailure, WebDAVScanFailureSeverity, WebDAVScanFailureType } from '../../services/api';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { modernTokens } from '../../theme';
|
||||
import StatsDashboard from './StatsDashboard';
|
||||
import FailureDetailsPanel from './FailureDetailsPanel';
|
||||
import RecommendationsSection from './RecommendationsSection';
|
||||
|
||||
// Severity configuration for styling
|
||||
const severityConfig = {
|
||||
critical: {
|
||||
color: modernTokens.colors.error[500],
|
||||
bgColor: modernTokens.colors.error[50],
|
||||
icon: ErrorIcon,
|
||||
label: 'Critical',
|
||||
},
|
||||
high: {
|
||||
color: modernTokens.colors.warning[600],
|
||||
bgColor: modernTokens.colors.warning[50],
|
||||
icon: WarningIcon,
|
||||
label: 'High',
|
||||
},
|
||||
medium: {
|
||||
color: modernTokens.colors.warning[500],
|
||||
bgColor: modernTokens.colors.warning[50],
|
||||
icon: InfoIcon,
|
||||
label: 'Medium',
|
||||
},
|
||||
low: {
|
||||
color: modernTokens.colors.info[500],
|
||||
bgColor: modernTokens.colors.info[50],
|
||||
icon: InfoIcon,
|
||||
label: 'Low',
|
||||
},
|
||||
};
|
||||
|
||||
// Failure type configuration
|
||||
const failureTypeConfig: Record<WebDAVScanFailureType, { label: string; description: string }> = {
|
||||
timeout: { label: 'Timeout', description: 'Request timed out' },
|
||||
path_too_long: { label: 'Path Too Long', description: 'File path exceeds system limits' },
|
||||
permission_denied: { label: 'Permission Denied', description: 'Access denied' },
|
||||
invalid_characters: { label: 'Invalid Characters', description: 'Path contains invalid characters' },
|
||||
network_error: { label: 'Network Error', description: 'Network connectivity issue' },
|
||||
server_error: { label: 'Server Error', description: 'Server returned an error' },
|
||||
xml_parse_error: { label: 'XML Parse Error', description: 'Failed to parse server response' },
|
||||
too_many_items: { label: 'Too Many Items', description: 'Directory contains too many files' },
|
||||
depth_limit: { label: 'Depth Limit', description: 'Directory nesting too deep' },
|
||||
size_limit: { label: 'Size Limit', description: 'Directory or file too large' },
|
||||
unknown: { label: 'Unknown', description: 'Unclassified error' },
|
||||
};
|
||||
|
||||
interface WebDAVScanFailuresProps {
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
const WebDAVScanFailures: React.FC<WebDAVScanFailuresProps> = ({
|
||||
autoRefresh = true,
|
||||
refreshInterval = 30000, // 30 seconds
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState<WebDAVScanFailureSeverity | 'all'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<WebDAVScanFailureType | 'all'>('all');
|
||||
const [expandedFailure, setExpandedFailure] = useState<string | null>(null);
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
|
||||
// Data state
|
||||
const [scanFailuresData, setScanFailuresData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Action states
|
||||
const [retryingFailures, setRetryingFailures] = useState<Set<string>>(new Set());
|
||||
const [excludingFailures, setExcludingFailures] = useState<Set<string>>(new Set());
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
// Fetch scan failures
|
||||
const fetchScanFailures = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await webdavService.getScanFailures();
|
||||
setScanFailuresData(response.data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch scan failures:', err);
|
||||
setError(err?.response?.data?.message || err.message || 'Failed to load scan failures');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-refresh effect
|
||||
useEffect(() => {
|
||||
fetchScanFailures();
|
||||
|
||||
if (autoRefresh && refreshInterval > 0) {
|
||||
const interval = setInterval(fetchScanFailures, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [fetchScanFailures, autoRefresh, refreshInterval]);
|
||||
|
||||
// Manual refetch
|
||||
const refetch = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
fetchScanFailures();
|
||||
}, [fetchScanFailures]);
|
||||
|
||||
// Filter failures based on search and filters
|
||||
const filteredFailures = useMemo(() => {
|
||||
if (!scanFailuresData?.failures) return [];
|
||||
|
||||
return scanFailuresData.failures.filter((failure) => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
if (!failure.directory_path.toLowerCase().includes(searchLower) &&
|
||||
!failure.error_message?.toLowerCase().includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Severity filter
|
||||
if (severityFilter !== 'all' && failure.failure_severity !== severityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all' && failure.failure_type !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show resolved filter
|
||||
if (!showResolved && failure.resolved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [scanFailuresData?.failures, searchQuery, severityFilter, typeFilter, showResolved]);
|
||||
|
||||
// Handle accordion expansion
|
||||
const handleAccordionChange = (failureId: string) => (
|
||||
event: React.SyntheticEvent,
|
||||
isExpanded: boolean
|
||||
) => {
|
||||
setExpandedFailure(isExpanded ? failureId : null);
|
||||
};
|
||||
|
||||
// Handle retry action
|
||||
const handleRetry = async (failure: WebDAVScanFailure, notes?: string) => {
|
||||
try {
|
||||
setRetryingFailures(prev => new Set(prev).add(failure.id));
|
||||
const response = await webdavService.retryFailure(failure.id, { notes });
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: `Retry scheduled for: ${response.data.directory_path}`,
|
||||
});
|
||||
|
||||
// Refresh the data
|
||||
await fetchScanFailures();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to retry scan failure:', error);
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to schedule retry: ${error?.response?.data?.message || error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setRetryingFailures(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(failure.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle exclude action
|
||||
const handleExclude = async (failure: WebDAVScanFailure, notes?: string, permanent = true) => {
|
||||
try {
|
||||
setExcludingFailures(prev => new Set(prev).add(failure.id));
|
||||
const response = await webdavService.excludeFailure(failure.id, { notes, permanent });
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: `Directory excluded: ${response.data.directory_path}`,
|
||||
});
|
||||
|
||||
// Refresh the data
|
||||
await fetchScanFailures();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to exclude directory:', error);
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to exclude directory: ${error?.response?.data?.message || error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setExcludingFailures(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(failure.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render severity chip
|
||||
const renderSeverityChip = (severity: WebDAVScanFailureSeverity) => {
|
||||
const config = severityConfig[severity];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={<Icon sx={{ fontSize: 16 }} />}
|
||||
label={config.label}
|
||||
size="small"
|
||||
sx={{
|
||||
color: config.color,
|
||||
backgroundColor: config.bgColor,
|
||||
borderColor: config.color,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render failure type chip
|
||||
const renderFailureTypeChip = (type: WebDAVScanFailureType) => {
|
||||
const config = failureTypeConfig[type];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={config.label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: modernTokens.colors.neutral[300],
|
||||
color: modernTokens.colors.neutral[700],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
boxShadow: modernTokens.shadows.sm,
|
||||
}}
|
||||
action={
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={refetch}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
Failed to load WebDAV scan failures: {error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
WebDAV Scan Failures
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
Monitor and manage directories that failed to scan during WebDAV synchronization
|
||||
</Typography>
|
||||
|
||||
{/* Statistics Dashboard */}
|
||||
{scanFailuresData?.stats && (
|
||||
<StatsDashboard
|
||||
stats={scanFailuresData.stats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search directories or error messages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: modernTokens.colors.neutral[400] }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: modernTokens.colors.neutral[0],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select
|
||||
value={severityFilter}
|
||||
label="Severity"
|
||||
onChange={(e) => setSeverityFilter(e.target.value as WebDAVScanFailureSeverity | 'all')}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.neutral[0],
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All Severities</MenuItem>
|
||||
<MenuItem value="critical">Critical</MenuItem>
|
||||
<MenuItem value="high">High</MenuItem>
|
||||
<MenuItem value="medium">Medium</MenuItem>
|
||||
<MenuItem value="low">Low</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
label="Type"
|
||||
onChange={(e) => setTypeFilter(e.target.value as WebDAVScanFailureType | 'all')}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.neutral[0],
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All Types</MenuItem>
|
||||
{Object.entries(failureTypeConfig).map(([type, config]) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{config.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={2}>
|
||||
<IconButton
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.primary[50],
|
||||
color: modernTokens.colors.primary[600],
|
||||
'&:hover': {
|
||||
backgroundColor: modernTokens.colors.primary[100],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<Stack spacing={2}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rectangular"
|
||||
height={120}
|
||||
sx={{ borderRadius: 2 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Failures List */}
|
||||
{!isLoading && (
|
||||
<Fade in={!isLoading}>
|
||||
<Box>
|
||||
{filteredFailures.length === 0 ? (
|
||||
<Card
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
backgroundColor: modernTokens.colors.neutral[50],
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<CheckCircleIcon
|
||||
sx={{
|
||||
fontSize: 64,
|
||||
color: modernTokens.colors.success[500],
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
No Scan Failures Found
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: modernTokens.colors.neutral[600] }}
|
||||
>
|
||||
{scanFailuresData?.failures.length === 0
|
||||
? 'All WebDAV directories are scanning successfully!'
|
||||
: 'Try adjusting your search criteria or filters.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{filteredFailures.map((failure) => (
|
||||
<Accordion
|
||||
key={failure.id}
|
||||
expanded={expandedFailure === failure.id}
|
||||
onChange={handleAccordionChange(failure.id)}
|
||||
sx={{
|
||||
boxShadow: modernTokens.shadows.sm,
|
||||
'&:before': { display: 'none' },
|
||||
border: `1px solid ${modernTokens.colors.neutral[200]}`,
|
||||
borderRadius: '12px !important',
|
||||
'&.Mui-expanded': {
|
||||
margin: 0,
|
||||
boxShadow: modernTokens.shadows.md,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
'& .MuiAccordionSummary-content': {
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
|
||||
{renderSeverityChip(failure.failure_severity)}
|
||||
{renderFailureTypeChip(failure.failure_type)}
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: modernTokens.colors.neutral[900],
|
||||
}}
|
||||
>
|
||||
{failure.directory_path}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: modernTokens.colors.neutral[600],
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{failure.consecutive_failures} consecutive failures •
|
||||
Last failed: {new Date(failure.last_failure_at).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{failure.user_excluded && (
|
||||
<Chip
|
||||
label="Excluded"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.neutral[100],
|
||||
color: modernTokens.colors.neutral[700],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{failure.resolved && (
|
||||
<Chip
|
||||
label="Resolved"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: modernTokens.colors.success[100],
|
||||
color: modernTokens.colors.success[700],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
<FailureDetailsPanel
|
||||
failure={failure}
|
||||
onRetry={handleRetry}
|
||||
onExclude={handleExclude}
|
||||
isRetrying={retryingFailures.has(failure.id)}
|
||||
isExcluding={excludingFailures.has(failure.id)}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Recommendations Section */}
|
||||
{filteredFailures.length > 0 && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<RecommendationsSection failures={filteredFailures} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebDAVScanFailures;
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
|
||||
import FailureDetailsPanel from '../FailureDetailsPanel';
|
||||
import { WebDAVScanFailure } from '../../../services/api';
|
||||
import { NotificationContext } from '../../../contexts/NotificationContext';
|
||||
import theme from '../../../theme';
|
||||
|
||||
const mockShowNotification = vi.fn();
|
||||
|
||||
const MockNotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<NotificationContext.Provider value={{ showNotification: mockShowNotification }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<MockNotificationProvider>
|
||||
{component}
|
||||
</MockNotificationProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const mockFailure: WebDAVScanFailure = {
|
||||
id: '1',
|
||||
directory_path: '/test/very/long/path/that/exceeds/normal/limits/and/causes/issues',
|
||||
failure_type: 'path_too_long',
|
||||
failure_severity: 'high',
|
||||
failure_count: 5,
|
||||
consecutive_failures: 3,
|
||||
first_failure_at: '2024-01-01T10:00:00Z',
|
||||
last_failure_at: '2024-01-01T12:00:00Z',
|
||||
next_retry_at: '2024-01-01T13:00:00Z',
|
||||
error_message: 'Path length exceeds maximum allowed (260 characters)',
|
||||
http_status_code: 400,
|
||||
user_excluded: false,
|
||||
user_notes: 'Previous attempt to shorten path failed',
|
||||
resolved: false,
|
||||
diagnostic_summary: {
|
||||
path_length: 85,
|
||||
directory_depth: 8,
|
||||
estimated_item_count: 500,
|
||||
response_time_ms: 5000,
|
||||
response_size_mb: 1.2,
|
||||
server_type: 'Apache/2.4.41',
|
||||
recommended_action: 'Shorten directory and file names to reduce the total path length.',
|
||||
can_retry: true,
|
||||
user_action_required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockOnRetry = vi.fn();
|
||||
const mockOnExclude = vi.fn();
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
describe('FailureDetailsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders failure details correctly', () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check basic information
|
||||
expect(screen.getByText('/test/very/long/path/that/exceeds/normal/limits/and/causes/issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 total • 3 consecutive')).toBeInTheDocument();
|
||||
expect(screen.getByText('400')).toBeInTheDocument(); // HTTP status
|
||||
|
||||
// Check recommended action
|
||||
expect(screen.getByText('Recommended Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shorten directory and file names to reduce the total path length.')).toBeInTheDocument();
|
||||
|
||||
// Check user notes
|
||||
expect(screen.getByText('User Notes:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Previous attempt to shorten path failed')).toBeInTheDocument();
|
||||
|
||||
// Check action buttons
|
||||
expect(screen.getByText('Retry Scan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Exclude Directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error message when present', () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Path length exceeds maximum allowed (260 characters)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows diagnostic details when expanded', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click to expand diagnostics
|
||||
const diagnosticButton = screen.getByText('Diagnostic Details');
|
||||
await userEvent.click(diagnosticButton);
|
||||
|
||||
// Check diagnostic values
|
||||
expect(screen.getByText('85')).toBeInTheDocument(); // Path length
|
||||
expect(screen.getByText('8')).toBeInTheDocument(); // Directory depth
|
||||
expect(screen.getByText('500')).toBeInTheDocument(); // Estimated items
|
||||
expect(screen.getByText('5.0s')).toBeInTheDocument(); // Response time
|
||||
expect(screen.getByText('1.2 MB')).toBeInTheDocument(); // Response size
|
||||
expect(screen.getByText('Apache/2.4.41')).toBeInTheDocument(); // Server type
|
||||
});
|
||||
|
||||
it('handles copy path functionality', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click copy button
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
const copyButton = copyButtons.find(button => button.getAttribute('aria-label') === 'Copy path' ||
|
||||
button.querySelector('svg[data-testid="ContentCopyIcon"]'));
|
||||
|
||||
if (copyButton) {
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
'/test/very/long/path/that/exceeds/normal/limits/and/causes/issues'
|
||||
);
|
||||
expect(mockShowNotification).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Directory path copied to clipboard',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('opens retry confirmation dialog when retry button is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText('Retry Scan');
|
||||
await userEvent.click(retryButton);
|
||||
|
||||
// Check dialog is open
|
||||
expect(screen.getByText('Retry WebDAV Scan')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This will attempt to scan/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry Now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRetry when retry is confirmed', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open retry dialog
|
||||
const retryButton = screen.getByText('Retry Scan');
|
||||
await userEvent.click(retryButton);
|
||||
|
||||
// Add notes
|
||||
const notesInput = screen.getByLabelText('Notes (optional)');
|
||||
await userEvent.type(notesInput, 'Attempting retry after path optimization');
|
||||
|
||||
// Confirm retry
|
||||
const confirmButton = screen.getByRole('button', { name: 'Retry Now' });
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(mockOnRetry).toHaveBeenCalledWith(mockFailure, 'Attempting retry after path optimization');
|
||||
});
|
||||
|
||||
it('opens exclude confirmation dialog when exclude button is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
const excludeButton = screen.getByText('Exclude Directory');
|
||||
await userEvent.click(excludeButton);
|
||||
|
||||
// Check dialog is open
|
||||
expect(screen.getByText('Exclude Directory from Scanning')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This will prevent/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Exclude Directory' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Permanently exclude (recommended)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onExclude when exclude is confirmed', async () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open exclude dialog
|
||||
const excludeButton = screen.getByText('Exclude Directory');
|
||||
await userEvent.click(excludeButton);
|
||||
|
||||
// Add notes and toggle permanent setting
|
||||
const notesInput = screen.getByLabelText('Notes (optional)');
|
||||
await userEvent.type(notesInput, 'Path too long to fix easily');
|
||||
|
||||
const permanentSwitch = screen.getByRole('checkbox');
|
||||
await userEvent.click(permanentSwitch); // Toggle off
|
||||
await userEvent.click(permanentSwitch); // Toggle back on
|
||||
|
||||
// Confirm exclude
|
||||
const confirmButton = screen.getByRole('button', { name: 'Exclude Directory' });
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(mockOnExclude).toHaveBeenCalledWith(mockFailure, 'Path too long to fix easily', true);
|
||||
});
|
||||
|
||||
it('shows loading states for retry and exclude buttons', () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
isRetrying={true}
|
||||
isExcluding={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText('Retry Scan');
|
||||
const excludeButton = screen.getByText('Exclude Directory');
|
||||
|
||||
expect(retryButton).toBeDisabled();
|
||||
expect(excludeButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides action buttons for resolved failures', () => {
|
||||
const resolvedFailure = { ...mockFailure, resolved: true };
|
||||
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={resolvedFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Retry Scan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Exclude Directory')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides action buttons for excluded failures', () => {
|
||||
const excludedFailure = { ...mockFailure, user_excluded: true };
|
||||
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={excludedFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Retry Scan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Exclude Directory')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides retry button when can_retry is false', () => {
|
||||
const nonRetryableFailure = {
|
||||
...mockFailure,
|
||||
diagnostic_summary: {
|
||||
...mockFailure.diagnostic_summary,
|
||||
can_retry: false,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={nonRetryableFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Retry Scan')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Exclude Directory')).toBeInTheDocument(); // Exclude should still be available
|
||||
});
|
||||
|
||||
it('formats durations correctly', () => {
|
||||
const failureWithDifferentTiming = {
|
||||
...mockFailure,
|
||||
diagnostic_summary: {
|
||||
...mockFailure.diagnostic_summary,
|
||||
response_time_ms: 500, // Should show as milliseconds
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={failureWithDifferentTiming}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Expand diagnostics to see the timing
|
||||
const diagnosticButton = screen.getByText('Diagnostic Details');
|
||||
fireEvent.click(diagnosticButton);
|
||||
|
||||
expect(screen.getByText('500ms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct recommendation styling based on user action required', () => {
|
||||
renderWithProviders(
|
||||
<FailureDetailsPanel
|
||||
failure={mockFailure}
|
||||
onRetry={mockOnRetry}
|
||||
onExclude={mockOnExclude}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show warning style since user_action_required is true
|
||||
expect(screen.getByText('Action required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Can retry')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
|
||||
import StatsDashboard from '../StatsDashboard';
|
||||
import { WebDAVScanFailureStats } from '../../../services/api';
|
||||
import theme from '../../../theme';
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const mockStats: WebDAVScanFailureStats = {
|
||||
active_failures: 15,
|
||||
resolved_failures: 35,
|
||||
excluded_directories: 5,
|
||||
critical_failures: 3,
|
||||
high_failures: 7,
|
||||
medium_failures: 4,
|
||||
low_failures: 1,
|
||||
ready_for_retry: 8,
|
||||
};
|
||||
|
||||
describe('StatsDashboard', () => {
|
||||
it('renders all stat cards with correct values', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
// Check title
|
||||
expect(screen.getByText('Scan Failure Statistics')).toBeInTheDocument();
|
||||
|
||||
// Check individual stat cards
|
||||
expect(screen.getByText('15')).toBeInTheDocument(); // Active failures
|
||||
expect(screen.getByText('3')).toBeInTheDocument(); // Critical failures
|
||||
expect(screen.getByText('7')).toBeInTheDocument(); // High failures
|
||||
expect(screen.getByText('4')).toBeInTheDocument(); // Medium failures
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // Low failures
|
||||
expect(screen.getByText('8')).toBeInTheDocument(); // Ready for retry
|
||||
expect(screen.getByText('35')).toBeInTheDocument(); // Resolved failures
|
||||
expect(screen.getByText('5')).toBeInTheDocument(); // Excluded directories
|
||||
|
||||
// Check labels
|
||||
expect(screen.getByText('Active Failures')).toBeInTheDocument();
|
||||
expect(screen.getByText('Critical')).toBeInTheDocument();
|
||||
expect(screen.getByText('High Priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medium Priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('Low Priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ready for Retry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resolved Failures')).toBeInTheDocument();
|
||||
expect(screen.getByText('Excluded Directories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates success rate correctly', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
// Total failures = active (15) + resolved (35) = 50
|
||||
// Success rate = resolved (35) / total (50) = 70%
|
||||
expect(screen.getByText('70.0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('35 of 50 failures resolved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays 100% success rate when no failures exist', () => {
|
||||
const noFailuresStats: WebDAVScanFailureStats = {
|
||||
active_failures: 0,
|
||||
resolved_failures: 0,
|
||||
excluded_directories: 0,
|
||||
critical_failures: 0,
|
||||
high_failures: 0,
|
||||
medium_failures: 0,
|
||||
low_failures: 0,
|
||||
ready_for_retry: 0,
|
||||
};
|
||||
|
||||
renderWithTheme(<StatsDashboard stats={noFailuresStats} />);
|
||||
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates percentages correctly for severity breakdown', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
// Total failures = 50
|
||||
// Critical: 3/50 = 6%
|
||||
// High: 7/50 = 14%
|
||||
// Medium: 4/50 = 8%
|
||||
// Low: 1/50 = 2%
|
||||
expect(screen.getByText('6.0% of total')).toBeInTheDocument();
|
||||
expect(screen.getByText('14.0% of total')).toBeInTheDocument();
|
||||
expect(screen.getByText('8.0% of total')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.0% of total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates retry percentage correctly', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
// Ready for retry: 8/15 active failures = 53.3%
|
||||
expect(screen.getByText('53.3% of total')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state with skeletons', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} isLoading={true} />);
|
||||
|
||||
// Should show skeleton cards instead of actual data
|
||||
const skeletons = document.querySelectorAll('.MuiSkeleton-root');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles zero active failures for retry percentage', () => {
|
||||
const zeroActiveStats: WebDAVScanFailureStats = {
|
||||
...mockStats,
|
||||
active_failures: 0,
|
||||
ready_for_retry: 0,
|
||||
};
|
||||
|
||||
renderWithTheme(<StatsDashboard stats={zeroActiveStats} />);
|
||||
|
||||
// Should not crash and should show 0% for retry percentage
|
||||
expect(screen.getByText('0')).toBeInTheDocument(); // Active failures
|
||||
expect(screen.getByText('0.0% of total')).toBeInTheDocument(); // Retry percentage when no active failures
|
||||
});
|
||||
|
||||
it('displays descriptive text for each stat', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
// Check descriptions
|
||||
expect(screen.getByText('Requiring attention')).toBeInTheDocument();
|
||||
expect(screen.getByText('Immediate action needed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Important issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Moderate issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minor issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Can be retried now')).toBeInTheDocument();
|
||||
expect(screen.getByText('Successfully resolved')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manually excluded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct hover effects to cards', () => {
|
||||
renderWithTheme(<StatsDashboard stats={mockStats} />);
|
||||
|
||||
const cards = document.querySelectorAll('.MuiCard-root');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
|
||||
// Cards should have transition styles for hover effects
|
||||
cards.forEach(card => {
|
||||
expect(card).toHaveStyle('transition: all 0.2s ease-in-out');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
|
||||
import WebDAVScanFailures from '../WebDAVScanFailures';
|
||||
import { webdavService } from '../../../services/api';
|
||||
import { NotificationContext } from '../../../contexts/NotificationContext';
|
||||
import theme from '../../../theme';
|
||||
|
||||
// Mock the webdav service
|
||||
vi.mock('../../../services/api', () => ({
|
||||
webdavService: {
|
||||
getScanFailures: vi.fn(),
|
||||
retryFailure: vi.fn(),
|
||||
excludeFailure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShowNotification = vi.fn();
|
||||
|
||||
const MockNotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<NotificationContext.Provider value={{ showNotification: mockShowNotification }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
<MockNotificationProvider>
|
||||
{component}
|
||||
</MockNotificationProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const mockScanFailuresData = {
|
||||
failures: [
|
||||
{
|
||||
id: '1',
|
||||
directory_path: '/test/path/long/directory/name',
|
||||
failure_type: 'timeout',
|
||||
failure_severity: 'high',
|
||||
failure_count: 3,
|
||||
consecutive_failures: 2,
|
||||
first_failure_at: '2024-01-01T10:00:00Z',
|
||||
last_failure_at: '2024-01-01T12:00:00Z',
|
||||
next_retry_at: '2024-01-01T13:00:00Z',
|
||||
error_message: 'Request timeout after 30 seconds',
|
||||
http_status_code: 408,
|
||||
user_excluded: false,
|
||||
user_notes: null,
|
||||
resolved: false,
|
||||
diagnostic_summary: {
|
||||
path_length: 45,
|
||||
directory_depth: 5,
|
||||
estimated_item_count: 1500,
|
||||
response_time_ms: 30000,
|
||||
response_size_mb: 2.5,
|
||||
server_type: 'Apache/2.4.41',
|
||||
recommended_action: 'Consider organizing files into smaller subdirectories or scanning during off-peak hours.',
|
||||
can_retry: true,
|
||||
user_action_required: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
directory_path: '/test/path/permissions',
|
||||
failure_type: 'permission_denied',
|
||||
failure_severity: 'critical',
|
||||
failure_count: 1,
|
||||
consecutive_failures: 1,
|
||||
first_failure_at: '2024-01-01T11:00:00Z',
|
||||
last_failure_at: '2024-01-01T11:00:00Z',
|
||||
next_retry_at: null,
|
||||
error_message: '403 Forbidden',
|
||||
http_status_code: 403,
|
||||
user_excluded: false,
|
||||
user_notes: null,
|
||||
resolved: false,
|
||||
diagnostic_summary: {
|
||||
path_length: 20,
|
||||
directory_depth: 3,
|
||||
estimated_item_count: null,
|
||||
response_time_ms: 1000,
|
||||
response_size_mb: null,
|
||||
server_type: 'Apache/2.4.41',
|
||||
recommended_action: 'Check that your WebDAV user has read access to this directory.',
|
||||
can_retry: false,
|
||||
user_action_required: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
active_failures: 2,
|
||||
resolved_failures: 5,
|
||||
excluded_directories: 1,
|
||||
critical_failures: 1,
|
||||
high_failures: 1,
|
||||
medium_failures: 0,
|
||||
low_failures: 0,
|
||||
ready_for_retry: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('WebDAVScanFailures', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
expect(screen.getByText('WebDAV Scan Failures')).toBeInTheDocument();
|
||||
// Should show skeleton loading
|
||||
expect(document.querySelectorAll('.MuiSkeleton-root')).toHaveLength(6); // Stats dashboard skeletons
|
||||
});
|
||||
|
||||
it('renders scan failures data successfully', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebDAV Scan Failures')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check if failures are rendered
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
expect(screen.getByText('/test/path/permissions')).toBeInTheDocument();
|
||||
|
||||
// Check severity chips
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
expect(screen.getByText('Critical')).toBeInTheDocument();
|
||||
|
||||
// Check failure type chips
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument();
|
||||
expect(screen.getByText('Permission Denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state when API fails', async () => {
|
||||
const errorMessage = 'Failed to fetch data';
|
||||
vi.mocked(webdavService.getScanFailures).mockRejectedValue(
|
||||
new Error(errorMessage)
|
||||
);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load WebDAV scan failures/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(new RegExp(errorMessage))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles search filtering correctly', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search for specific path
|
||||
const searchInput = screen.getByPlaceholderText('Search directories or error messages...');
|
||||
await userEvent.type(searchInput, 'permissions');
|
||||
|
||||
// Should only show the permissions failure
|
||||
expect(screen.queryByText('/test/path/long/directory/name')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('/test/path/permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles severity filtering correctly', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Filter by critical severity
|
||||
const severitySelect = screen.getByLabelText('Severity');
|
||||
fireEvent.mouseDown(severitySelect);
|
||||
await userEvent.click(screen.getByText('Critical'));
|
||||
|
||||
// Should only show the critical failure
|
||||
expect(screen.queryByText('/test/path/long/directory/name')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('/test/path/permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands failure details when clicked', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first failure to expand it
|
||||
const firstFailure = screen.getByText('/test/path/long/directory/name');
|
||||
await userEvent.click(firstFailure);
|
||||
|
||||
// Should show detailed information
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Request timeout after 30 seconds')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recommended Action')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles retry action correctly', async () => {
|
||||
const mockRetryResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Retry scheduled',
|
||||
directory_path: '/test/path/long/directory/name',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
vi.mocked(webdavService.retryFailure).mockResolvedValue(mockRetryResponse as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand the first failure
|
||||
const firstFailure = screen.getByText('/test/path/long/directory/name');
|
||||
await userEvent.click(firstFailure);
|
||||
|
||||
// Wait for details to load and click retry
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Retry Scan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const retryButton = screen.getByText('Retry Scan');
|
||||
await userEvent.click(retryButton);
|
||||
|
||||
// Should open confirmation dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Retry WebDAV Scan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm retry
|
||||
const confirmButton = screen.getByRole('button', { name: 'Retry Now' });
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should call the retry API
|
||||
await waitFor(() => {
|
||||
expect(webdavService.retryFailure).toHaveBeenCalledWith('1', { notes: undefined });
|
||||
});
|
||||
|
||||
// Should show success notification
|
||||
expect(mockShowNotification).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Retry scheduled for: /test/path/long/directory/name',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles exclude action correctly', async () => {
|
||||
const mockExcludeResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Directory excluded',
|
||||
directory_path: '/test/path/long/directory/name',
|
||||
permanent: true,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
vi.mocked(webdavService.excludeFailure).mockResolvedValue(mockExcludeResponse as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand the first failure
|
||||
const firstFailure = screen.getByText('/test/path/long/directory/name');
|
||||
await userEvent.click(firstFailure);
|
||||
|
||||
// Wait for details to load and click exclude
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Exclude Directory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const excludeButton = screen.getByText('Exclude Directory');
|
||||
await userEvent.click(excludeButton);
|
||||
|
||||
// Should open confirmation dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Exclude Directory from Scanning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm exclude
|
||||
const confirmButton = screen.getByRole('button', { name: 'Exclude Directory' });
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// Should call the exclude API
|
||||
await waitFor(() => {
|
||||
expect(webdavService.excludeFailure).toHaveBeenCalledWith('1', {
|
||||
notes: undefined,
|
||||
permanent: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Should show success notification
|
||||
expect(mockShowNotification).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Directory excluded: /test/path/long/directory/name',
|
||||
});
|
||||
});
|
||||
|
||||
it('displays empty state when no failures exist', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: {
|
||||
failures: [],
|
||||
stats: {
|
||||
active_failures: 0,
|
||||
resolved_failures: 0,
|
||||
excluded_directories: 0,
|
||||
critical_failures: 0,
|
||||
high_failures: 0,
|
||||
medium_failures: 0,
|
||||
low_failures: 0,
|
||||
ready_for_retry: 0,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No Scan Failures Found')).toBeInTheDocument();
|
||||
expect(screen.getByText('All WebDAV directories are scanning successfully!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes data when refresh button is clicked', async () => {
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/test/path/long/directory/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click refresh button
|
||||
const refreshButton = screen.getByRole('button', { name: '' }); // IconButton without accessible name
|
||||
await userEvent.click(refreshButton);
|
||||
|
||||
// Should call API again
|
||||
expect(webdavService.getScanFailures).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('auto-refreshes data when autoRefresh is enabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures autoRefresh={true} refreshInterval={1000} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webdavService.getScanFailures).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webdavService.getScanFailures).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not auto-refresh when autoRefresh is disabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
vi.mocked(webdavService.getScanFailures).mockResolvedValue({
|
||||
data: mockScanFailuresData,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<WebDAVScanFailures autoRefresh={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(webdavService.getScanFailures).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
// Should still only be called once
|
||||
expect(webdavService.getScanFailures).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { default } from './WebDAVScanFailures';
|
||||
export { default as StatsDashboard } from './StatsDashboard';
|
||||
export { default as FailureDetailsPanel } from './FailureDetailsPanel';
|
||||
export { default as RecommendationsSection } from './RecommendationsSection';
|
||||
|
|
@ -696,6 +696,117 @@ export const userWatchService = {
|
|||
},
|
||||
}
|
||||
|
||||
// WebDAV Scan Failure Types
|
||||
export interface WebDAVScanFailure {
|
||||
id: string
|
||||
directory_path: string
|
||||
failure_type: WebDAVScanFailureType
|
||||
failure_severity: WebDAVScanFailureSeverity
|
||||
failure_count: number
|
||||
consecutive_failures: number
|
||||
first_failure_at: string
|
||||
last_failure_at: string
|
||||
next_retry_at?: string
|
||||
error_message?: string
|
||||
http_status_code?: number
|
||||
user_excluded: boolean
|
||||
user_notes?: string
|
||||
resolved: boolean
|
||||
diagnostic_summary: WebDAVFailureDiagnostics
|
||||
}
|
||||
|
||||
export type WebDAVScanFailureType =
|
||||
| 'timeout'
|
||||
| 'path_too_long'
|
||||
| 'permission_denied'
|
||||
| 'invalid_characters'
|
||||
| 'network_error'
|
||||
| 'server_error'
|
||||
| 'xml_parse_error'
|
||||
| 'too_many_items'
|
||||
| 'depth_limit'
|
||||
| 'size_limit'
|
||||
| 'unknown'
|
||||
|
||||
export type WebDAVScanFailureSeverity =
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'critical'
|
||||
|
||||
export interface WebDAVFailureDiagnostics {
|
||||
path_length?: number
|
||||
directory_depth?: number
|
||||
estimated_item_count?: number
|
||||
response_time_ms?: number
|
||||
response_size_mb?: number
|
||||
server_type?: string
|
||||
recommended_action: string
|
||||
can_retry: boolean
|
||||
user_action_required: boolean
|
||||
}
|
||||
|
||||
export interface WebDAVScanFailureStats {
|
||||
active_failures: number
|
||||
resolved_failures: number
|
||||
excluded_directories: number
|
||||
critical_failures: number
|
||||
high_failures: number
|
||||
medium_failures: number
|
||||
low_failures: number
|
||||
ready_for_retry: number
|
||||
}
|
||||
|
||||
export interface WebDAVScanFailuresResponse {
|
||||
failures: WebDAVScanFailure[]
|
||||
stats: WebDAVScanFailureStats
|
||||
}
|
||||
|
||||
export interface RetryFailureRequest {
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ExcludeFailureRequest {
|
||||
notes?: string
|
||||
permanent: boolean
|
||||
}
|
||||
|
||||
export interface RetryResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
directory_path: string
|
||||
}
|
||||
|
||||
export interface ExcludeResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
directory_path: string
|
||||
permanent: boolean
|
||||
}
|
||||
|
||||
// WebDAV Scan Failures Service
|
||||
export const webdavService = {
|
||||
getScanFailures: () => {
|
||||
return api.get<WebDAVScanFailuresResponse>('/webdav/scan-failures')
|
||||
},
|
||||
|
||||
getScanFailure: (id: string) => {
|
||||
return api.get<WebDAVScanFailure>(`/webdav/scan-failures/${id}`)
|
||||
},
|
||||
|
||||
retryFailure: (id: string, request: RetryFailureRequest) => {
|
||||
return api.post<RetryResponse>(`/webdav/scan-failures/${id}/retry`, request)
|
||||
},
|
||||
|
||||
excludeFailure: (id: string, request: ExcludeFailureRequest) => {
|
||||
return api.post<ExcludeResponse>(`/webdav/scan-failures/${id}/exclude`, request)
|
||||
},
|
||||
|
||||
getRetryCandidates: () => {
|
||||
return api.get<{ directories: string[], count: number }>('/webdav/scan-failures/retry-candidates')
|
||||
}
|
||||
}
|
||||
|
||||
export const sourcesService = {
|
||||
triggerSync: (sourceId: string) => {
|
||||
return api.post(`/sources/${sourceId}/sync`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
-- WebDAV Scan Failures Tracking System
|
||||
-- This migration creates a comprehensive failure tracking system for WebDAV directory scans
|
||||
|
||||
-- Create enum for failure types
|
||||
CREATE TYPE webdav_scan_failure_type AS ENUM (
|
||||
'timeout', -- Directory scan took too long
|
||||
'path_too_long', -- Path exceeds filesystem limits
|
||||
'permission_denied', -- Access denied
|
||||
'invalid_characters',-- Invalid characters in path
|
||||
'network_error', -- Network connectivity issues
|
||||
'server_error', -- Server returned error (404, 500, etc.)
|
||||
'xml_parse_error', -- Malformed XML response
|
||||
'too_many_items', -- Directory has too many items
|
||||
'depth_limit', -- Directory depth exceeds limit
|
||||
'size_limit', -- Directory size exceeds limit
|
||||
'unknown' -- Unknown error type
|
||||
);
|
||||
|
||||
-- Create enum for failure severity
|
||||
CREATE TYPE webdav_scan_failure_severity AS ENUM (
|
||||
'low', -- Can be retried, likely temporary
|
||||
'medium', -- May succeed with adjustments
|
||||
'high', -- Unlikely to succeed without intervention
|
||||
'critical' -- Will never succeed, permanent issue
|
||||
);
|
||||
|
||||
-- Main table for tracking scan failures
|
||||
CREATE TABLE IF NOT EXISTS webdav_scan_failures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
directory_path TEXT NOT NULL,
|
||||
|
||||
-- Failure tracking
|
||||
failure_type webdav_scan_failure_type NOT NULL DEFAULT 'unknown',
|
||||
failure_severity webdav_scan_failure_severity NOT NULL DEFAULT 'medium',
|
||||
failure_count INTEGER NOT NULL DEFAULT 1,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
-- Timestamps
|
||||
first_failure_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_failure_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
last_retry_at TIMESTAMP WITH TIME ZONE,
|
||||
next_retry_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Error details
|
||||
error_message TEXT,
|
||||
error_code TEXT,
|
||||
http_status_code INTEGER,
|
||||
|
||||
-- Diagnostic information
|
||||
response_time_ms INTEGER, -- How long the request took
|
||||
response_size_bytes BIGINT, -- Size of response (for timeout diagnosis)
|
||||
path_length INTEGER, -- Length of the path
|
||||
directory_depth INTEGER, -- How deep in the hierarchy
|
||||
estimated_item_count INTEGER, -- Estimated number of items
|
||||
server_type TEXT, -- WebDAV server type
|
||||
server_version TEXT, -- Server version if available
|
||||
|
||||
-- Additional context
|
||||
diagnostic_data JSONB, -- Flexible field for additional diagnostics
|
||||
|
||||
-- User actions
|
||||
user_excluded BOOLEAN DEFAULT FALSE, -- User marked as permanently excluded
|
||||
user_notes TEXT, -- User-provided notes about the issue
|
||||
|
||||
-- Retry strategy
|
||||
retry_strategy TEXT, -- Strategy for retrying (exponential, linear, etc.)
|
||||
max_retries INTEGER DEFAULT 5, -- Maximum number of retries
|
||||
retry_delay_seconds INTEGER DEFAULT 300, -- Base delay between retries
|
||||
|
||||
-- Resolution tracking
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
resolved_at TIMESTAMP WITH TIME ZONE,
|
||||
resolution_method TEXT, -- How it was resolved
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Unique constraint to prevent duplicates
|
||||
CONSTRAINT unique_user_directory_failure UNIQUE (user_id, directory_path)
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX idx_webdav_scan_failures_user_id ON webdav_scan_failures(user_id);
|
||||
CREATE INDEX idx_webdav_scan_failures_severity ON webdav_scan_failures(failure_severity);
|
||||
CREATE INDEX idx_webdav_scan_failures_type ON webdav_scan_failures(failure_type);
|
||||
CREATE INDEX idx_webdav_scan_failures_resolved ON webdav_scan_failures(resolved);
|
||||
CREATE INDEX idx_webdav_scan_failures_next_retry ON webdav_scan_failures(next_retry_at) WHERE NOT resolved AND NOT user_excluded;
|
||||
CREATE INDEX idx_webdav_scan_failures_path ON webdav_scan_failures(directory_path);
|
||||
|
||||
-- Function to calculate next retry time with exponential backoff
|
||||
CREATE OR REPLACE FUNCTION calculate_next_retry_time(
|
||||
failure_count INTEGER,
|
||||
base_delay_seconds INTEGER,
|
||||
max_delay_seconds INTEGER DEFAULT 86400 -- 24 hours max
|
||||
) RETURNS TIMESTAMP WITH TIME ZONE AS $$
|
||||
DECLARE
|
||||
delay_seconds INTEGER;
|
||||
BEGIN
|
||||
-- Exponential backoff: delay = base * 2^(failure_count - 1)
|
||||
-- Cap at max_delay_seconds
|
||||
delay_seconds := LEAST(
|
||||
base_delay_seconds * POWER(2, LEAST(failure_count - 1, 10)),
|
||||
max_delay_seconds
|
||||
);
|
||||
|
||||
RETURN NOW() + (delay_seconds || ' seconds')::INTERVAL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Function to record or update a scan failure
|
||||
CREATE OR REPLACE FUNCTION record_webdav_scan_failure(
|
||||
p_user_id UUID,
|
||||
p_directory_path TEXT,
|
||||
p_failure_type webdav_scan_failure_type,
|
||||
p_error_message TEXT,
|
||||
p_error_code TEXT DEFAULT NULL,
|
||||
p_http_status_code INTEGER DEFAULT NULL,
|
||||
p_response_time_ms INTEGER DEFAULT NULL,
|
||||
p_response_size_bytes BIGINT DEFAULT NULL,
|
||||
p_diagnostic_data JSONB DEFAULT NULL
|
||||
) RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_failure_id UUID;
|
||||
v_existing_count INTEGER;
|
||||
v_severity webdav_scan_failure_severity;
|
||||
BEGIN
|
||||
-- Determine severity based on failure type
|
||||
v_severity := CASE p_failure_type
|
||||
WHEN 'timeout' THEN 'medium'::webdav_scan_failure_severity
|
||||
WHEN 'path_too_long' THEN 'critical'::webdav_scan_failure_severity
|
||||
WHEN 'permission_denied' THEN 'high'::webdav_scan_failure_severity
|
||||
WHEN 'invalid_characters' THEN 'critical'::webdav_scan_failure_severity
|
||||
WHEN 'network_error' THEN 'low'::webdav_scan_failure_severity
|
||||
WHEN 'server_error' THEN
|
||||
CASE
|
||||
WHEN p_http_status_code = 404 THEN 'critical'::webdav_scan_failure_severity
|
||||
WHEN p_http_status_code >= 500 THEN 'medium'::webdav_scan_failure_severity
|
||||
ELSE 'medium'::webdav_scan_failure_severity
|
||||
END
|
||||
WHEN 'xml_parse_error' THEN 'high'::webdav_scan_failure_severity
|
||||
WHEN 'too_many_items' THEN 'high'::webdav_scan_failure_severity
|
||||
WHEN 'depth_limit' THEN 'high'::webdav_scan_failure_severity
|
||||
WHEN 'size_limit' THEN 'high'::webdav_scan_failure_severity
|
||||
ELSE 'medium'::webdav_scan_failure_severity
|
||||
END;
|
||||
|
||||
-- Insert or update the failure record
|
||||
INSERT INTO webdav_scan_failures (
|
||||
user_id,
|
||||
directory_path,
|
||||
failure_type,
|
||||
failure_severity,
|
||||
failure_count,
|
||||
consecutive_failures,
|
||||
error_message,
|
||||
error_code,
|
||||
http_status_code,
|
||||
response_time_ms,
|
||||
response_size_bytes,
|
||||
path_length,
|
||||
directory_depth,
|
||||
diagnostic_data,
|
||||
next_retry_at
|
||||
) VALUES (
|
||||
p_user_id,
|
||||
p_directory_path,
|
||||
p_failure_type,
|
||||
v_severity,
|
||||
1,
|
||||
1,
|
||||
p_error_message,
|
||||
p_error_code,
|
||||
p_http_status_code,
|
||||
p_response_time_ms,
|
||||
p_response_size_bytes,
|
||||
LENGTH(p_directory_path),
|
||||
array_length(string_to_array(p_directory_path, '/'), 1) - 1,
|
||||
p_diagnostic_data,
|
||||
calculate_next_retry_time(1, 300, 86400)
|
||||
)
|
||||
ON CONFLICT (user_id, directory_path) DO UPDATE SET
|
||||
failure_type = EXCLUDED.failure_type,
|
||||
failure_severity = EXCLUDED.failure_severity,
|
||||
failure_count = webdav_scan_failures.failure_count + 1,
|
||||
consecutive_failures = webdav_scan_failures.consecutive_failures + 1,
|
||||
last_failure_at = NOW(),
|
||||
error_message = EXCLUDED.error_message,
|
||||
error_code = EXCLUDED.error_code,
|
||||
http_status_code = EXCLUDED.http_status_code,
|
||||
response_time_ms = EXCLUDED.response_time_ms,
|
||||
response_size_bytes = EXCLUDED.response_size_bytes,
|
||||
diagnostic_data = COALESCE(EXCLUDED.diagnostic_data, webdav_scan_failures.diagnostic_data),
|
||||
next_retry_at = calculate_next_retry_time(
|
||||
webdav_scan_failures.failure_count + 1,
|
||||
webdav_scan_failures.retry_delay_seconds,
|
||||
86400
|
||||
),
|
||||
resolved = FALSE,
|
||||
updated_at = NOW()
|
||||
RETURNING id INTO v_failure_id;
|
||||
|
||||
RETURN v_failure_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to reset a failure for retry
|
||||
CREATE OR REPLACE FUNCTION reset_webdav_scan_failure(
|
||||
p_user_id UUID,
|
||||
p_directory_path TEXT
|
||||
) RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_updated INTEGER;
|
||||
BEGIN
|
||||
UPDATE webdav_scan_failures
|
||||
SET
|
||||
consecutive_failures = 0,
|
||||
last_retry_at = NOW(),
|
||||
next_retry_at = NOW(), -- Retry immediately
|
||||
resolved = FALSE,
|
||||
user_excluded = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id
|
||||
AND directory_path = p_directory_path
|
||||
AND NOT resolved;
|
||||
|
||||
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||
RETURN v_updated > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to mark a failure as resolved
|
||||
CREATE OR REPLACE FUNCTION resolve_webdav_scan_failure(
|
||||
p_user_id UUID,
|
||||
p_directory_path TEXT,
|
||||
p_resolution_method TEXT DEFAULT 'automatic'
|
||||
) RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_updated INTEGER;
|
||||
BEGIN
|
||||
UPDATE webdav_scan_failures
|
||||
SET
|
||||
resolved = TRUE,
|
||||
resolved_at = NOW(),
|
||||
resolution_method = p_resolution_method,
|
||||
consecutive_failures = 0,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id
|
||||
AND directory_path = p_directory_path
|
||||
AND NOT resolved;
|
||||
|
||||
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||
RETURN v_updated > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- View for active failures that need attention
|
||||
CREATE VIEW active_webdav_scan_failures AS
|
||||
SELECT
|
||||
wsf.*,
|
||||
u.username,
|
||||
u.email,
|
||||
CASE
|
||||
WHEN wsf.failure_count > 10 THEN 'chronic'
|
||||
WHEN wsf.failure_count > 5 THEN 'persistent'
|
||||
WHEN wsf.failure_count > 2 THEN 'recurring'
|
||||
ELSE 'recent'
|
||||
END as failure_status,
|
||||
CASE
|
||||
WHEN wsf.next_retry_at < NOW() THEN 'ready_for_retry'
|
||||
WHEN wsf.user_excluded THEN 'excluded'
|
||||
WHEN wsf.failure_severity = 'critical' THEN 'needs_intervention'
|
||||
ELSE 'scheduled'
|
||||
END as action_status
|
||||
FROM webdav_scan_failures wsf
|
||||
JOIN users u ON wsf.user_id = u.id
|
||||
WHERE NOT wsf.resolved;
|
||||
|
||||
-- Trigger to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_webdav_scan_failures_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_webdav_scan_failures_updated_at
|
||||
BEFORE UPDATE ON webdav_scan_failures
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_webdav_scan_failures_updated_at();
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE webdav_scan_failures IS 'Tracks failures during WebDAV directory scanning with detailed diagnostics';
|
||||
COMMENT ON COLUMN webdav_scan_failures.failure_type IS 'Categorized type of failure for analysis and handling';
|
||||
COMMENT ON COLUMN webdav_scan_failures.failure_severity IS 'Severity level determining retry strategy and user notification';
|
||||
COMMENT ON COLUMN webdav_scan_failures.diagnostic_data IS 'Flexible JSON field for storing additional diagnostic information';
|
||||
COMMENT ON COLUMN webdav_scan_failures.user_excluded IS 'User has marked this directory to be permanently excluded from scanning';
|
||||
COMMENT ON COLUMN webdav_scan_failures.consecutive_failures IS 'Number of consecutive failures without a successful scan';
|
||||
356
src/db/webdav.rs
356
src/db/webdav.rs
|
|
@ -611,4 +611,360 @@ impl Database {
|
|||
tx.commit().await?;
|
||||
Ok((updated_directories, deleted_count))
|
||||
}
|
||||
|
||||
// ===== WebDAV Scan Failure Tracking Methods =====
|
||||
|
||||
/// Record a new scan failure or increment existing failure count
|
||||
pub async fn record_scan_failure(&self, failure: &crate::models::CreateWebDAVScanFailure) -> Result<Uuid> {
|
||||
let failure_type_str = failure.failure_type.to_string();
|
||||
|
||||
// Classify the error to determine appropriate failure type and severity
|
||||
let (failure_type, severity) = self.classify_scan_error(&failure);
|
||||
|
||||
let mut diagnostic_data = failure.diagnostic_data.clone().unwrap_or(serde_json::json!({}));
|
||||
|
||||
// Add additional diagnostic information
|
||||
if let Some(data) = diagnostic_data.as_object_mut() {
|
||||
data.insert("path_length".to_string(), serde_json::json!(failure.directory_path.len()));
|
||||
data.insert("directory_depth".to_string(), serde_json::json!(failure.directory_path.matches('/').count()));
|
||||
|
||||
if let Some(server_type) = &failure.server_type {
|
||||
data.insert("server_type".to_string(), serde_json::json!(server_type));
|
||||
}
|
||||
if let Some(server_version) = &failure.server_version {
|
||||
data.insert("server_version".to_string(), serde_json::json!(server_version));
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"SELECT record_webdav_scan_failure($1, $2, $3, $4, $5, $6, $7, $8, $9) as failure_id"#
|
||||
)
|
||||
.bind(failure.user_id)
|
||||
.bind(&failure.directory_path)
|
||||
.bind(failure_type_str)
|
||||
.bind(&failure.error_message)
|
||||
.bind(&failure.error_code)
|
||||
.bind(failure.http_status_code)
|
||||
.bind(failure.response_time_ms)
|
||||
.bind(failure.response_size_bytes)
|
||||
.bind(&diagnostic_data)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("failure_id"))
|
||||
}
|
||||
|
||||
/// Get all scan failures for a user
|
||||
pub async fn get_scan_failures(&self, user_id: Uuid, include_resolved: bool) -> Result<Vec<crate::models::WebDAVScanFailure>> {
|
||||
let query = if include_resolved {
|
||||
r#"SELECT * FROM webdav_scan_failures
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_failure_at DESC"#
|
||||
} else {
|
||||
r#"SELECT * FROM webdav_scan_failures
|
||||
WHERE user_id = $1 AND NOT resolved AND NOT user_excluded
|
||||
ORDER BY failure_severity DESC, last_failure_at DESC"#
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, crate::models::WebDAVScanFailure>(query)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get failure count for a specific directory
|
||||
pub async fn get_failure_count(&self, user_id: Uuid, directory_path: &str) -> Result<Option<i32>> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT failure_count FROM webdav_scan_failures
|
||||
WHERE user_id = $1 AND directory_path = $2"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(directory_path)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get("failure_count")))
|
||||
}
|
||||
|
||||
/// Check if a directory is a known failure that should be skipped
|
||||
pub async fn is_known_failure(&self, user_id: Uuid, directory_path: &str) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT 1 FROM webdav_scan_failures
|
||||
WHERE user_id = $1 AND directory_path = $2
|
||||
AND NOT resolved
|
||||
AND (user_excluded = TRUE OR
|
||||
(failure_severity IN ('critical', 'high') AND failure_count > 3) OR
|
||||
(next_retry_at IS NULL OR next_retry_at > NOW()))"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(directory_path)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Get directories ready for retry
|
||||
pub async fn get_directories_ready_for_retry(&self, user_id: Uuid) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT directory_path FROM webdav_scan_failures
|
||||
WHERE user_id = $1
|
||||
AND NOT resolved
|
||||
AND NOT user_excluded
|
||||
AND next_retry_at <= NOW()
|
||||
AND failure_count < max_retries
|
||||
ORDER BY failure_severity ASC, next_retry_at ASC
|
||||
LIMIT 10"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|row| row.get("directory_path")).collect())
|
||||
}
|
||||
|
||||
/// Reset a failure for retry
|
||||
pub async fn reset_scan_failure(&self, user_id: Uuid, directory_path: &str) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT reset_webdav_scan_failure($1, $2) as success"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(directory_path)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("success"))
|
||||
}
|
||||
|
||||
/// Mark a failure as resolved
|
||||
pub async fn resolve_scan_failure(&self, user_id: Uuid, directory_path: &str, resolution_method: &str) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT resolve_webdav_scan_failure($1, $2, $3) as success"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(directory_path)
|
||||
.bind(resolution_method)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.get("success"))
|
||||
}
|
||||
|
||||
/// Mark a directory as permanently excluded by user
|
||||
pub async fn exclude_directory_from_scan(&self, user_id: Uuid, directory_path: &str, user_notes: Option<&str>) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"UPDATE webdav_scan_failures
|
||||
SET user_excluded = TRUE,
|
||||
user_notes = COALESCE($3, user_notes),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1 AND directory_path = $2"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(directory_path)
|
||||
.bind(user_notes)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get scan failure statistics for a user
|
||||
pub async fn get_scan_failure_stats(&self, user_id: Uuid) -> Result<serde_json::Value> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT
|
||||
COUNT(*) FILTER (WHERE NOT resolved) as active_failures,
|
||||
COUNT(*) FILTER (WHERE resolved) as resolved_failures,
|
||||
COUNT(*) FILTER (WHERE user_excluded) as excluded_directories,
|
||||
COUNT(*) FILTER (WHERE failure_severity = 'critical' AND NOT resolved) as critical_failures,
|
||||
COUNT(*) FILTER (WHERE failure_severity = 'high' AND NOT resolved) as high_failures,
|
||||
COUNT(*) FILTER (WHERE failure_severity = 'medium' AND NOT resolved) as medium_failures,
|
||||
COUNT(*) FILTER (WHERE failure_severity = 'low' AND NOT resolved) as low_failures,
|
||||
COUNT(*) FILTER (WHERE next_retry_at <= NOW() AND NOT resolved AND NOT user_excluded) as ready_for_retry
|
||||
FROM webdav_scan_failures
|
||||
WHERE user_id = $1"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"active_failures": row.get::<i64, _>("active_failures"),
|
||||
"resolved_failures": row.get::<i64, _>("resolved_failures"),
|
||||
"excluded_directories": row.get::<i64, _>("excluded_directories"),
|
||||
"critical_failures": row.get::<i64, _>("critical_failures"),
|
||||
"high_failures": row.get::<i64, _>("high_failures"),
|
||||
"medium_failures": row.get::<i64, _>("medium_failures"),
|
||||
"low_failures": row.get::<i64, _>("low_failures"),
|
||||
"ready_for_retry": row.get::<i64, _>("ready_for_retry"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Helper function to classify scan errors
|
||||
fn classify_scan_error(&self, failure: &crate::models::CreateWebDAVScanFailure) -> (String, String) {
|
||||
use crate::models::WebDAVScanFailureType;
|
||||
|
||||
let failure_type = &failure.failure_type;
|
||||
let error_msg = failure.error_message.to_lowercase();
|
||||
let status_code = failure.http_status_code;
|
||||
|
||||
// Determine severity based on error characteristics
|
||||
let severity = match failure_type {
|
||||
WebDAVScanFailureType::PathTooLong |
|
||||
WebDAVScanFailureType::InvalidCharacters => "critical",
|
||||
|
||||
WebDAVScanFailureType::PermissionDenied |
|
||||
WebDAVScanFailureType::XmlParseError |
|
||||
WebDAVScanFailureType::TooManyItems |
|
||||
WebDAVScanFailureType::DepthLimit |
|
||||
WebDAVScanFailureType::SizeLimit => "high",
|
||||
|
||||
WebDAVScanFailureType::Timeout |
|
||||
WebDAVScanFailureType::ServerError => {
|
||||
if let Some(code) = status_code {
|
||||
if code == 404 {
|
||||
"critical"
|
||||
} else if code >= 500 {
|
||||
"medium"
|
||||
} else {
|
||||
"medium"
|
||||
}
|
||||
} else {
|
||||
"medium"
|
||||
}
|
||||
},
|
||||
|
||||
WebDAVScanFailureType::NetworkError => "low",
|
||||
|
||||
WebDAVScanFailureType::Unknown => {
|
||||
// Try to infer from error message
|
||||
if error_msg.contains("timeout") || error_msg.contains("timed out") {
|
||||
"medium"
|
||||
} else if error_msg.contains("permission") || error_msg.contains("forbidden") {
|
||||
"high"
|
||||
} else if error_msg.contains("not found") || error_msg.contains("404") {
|
||||
"critical"
|
||||
} else {
|
||||
"medium"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(failure_type.to_string(), severity.to_string())
|
||||
}
|
||||
|
||||
/// Get detailed failure information with diagnostics
|
||||
pub async fn get_scan_failure_with_diagnostics(&self, user_id: Uuid, failure_id: Uuid) -> Result<Option<crate::models::WebDAVScanFailureResponse>> {
|
||||
let failure = sqlx::query_as::<_, crate::models::WebDAVScanFailure>(
|
||||
r#"SELECT * FROM webdav_scan_failures
|
||||
WHERE user_id = $1 AND id = $2"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(failure_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
match failure {
|
||||
Some(f) => {
|
||||
let diagnostics = self.build_failure_diagnostics(&f);
|
||||
|
||||
Ok(Some(crate::models::WebDAVScanFailureResponse {
|
||||
id: f.id,
|
||||
directory_path: f.directory_path,
|
||||
failure_type: f.failure_type,
|
||||
failure_severity: f.failure_severity,
|
||||
failure_count: f.failure_count,
|
||||
consecutive_failures: f.consecutive_failures,
|
||||
first_failure_at: f.first_failure_at,
|
||||
last_failure_at: f.last_failure_at,
|
||||
next_retry_at: f.next_retry_at,
|
||||
error_message: f.error_message,
|
||||
http_status_code: f.http_status_code,
|
||||
user_excluded: f.user_excluded,
|
||||
user_notes: f.user_notes,
|
||||
resolved: f.resolved,
|
||||
diagnostic_summary: diagnostics,
|
||||
}))
|
||||
},
|
||||
None => Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build diagnostic summary for a failure
|
||||
fn build_failure_diagnostics(&self, failure: &crate::models::WebDAVScanFailure) -> crate::models::WebDAVFailureDiagnostics {
|
||||
use crate::models::{WebDAVScanFailureType, WebDAVScanFailureSeverity};
|
||||
|
||||
let response_size_mb = failure.response_size_bytes.map(|b| b as f64 / 1_048_576.0);
|
||||
|
||||
let (recommended_action, can_retry, user_action_required) = match (&failure.failure_type, &failure.failure_severity) {
|
||||
(WebDAVScanFailureType::PathTooLong, _) => (
|
||||
"Path exceeds system limits. Consider reorganizing directory structure.".to_string(),
|
||||
false,
|
||||
true
|
||||
),
|
||||
(WebDAVScanFailureType::InvalidCharacters, _) => (
|
||||
"Path contains invalid characters. Rename the directory to remove special characters.".to_string(),
|
||||
false,
|
||||
true
|
||||
),
|
||||
(WebDAVScanFailureType::PermissionDenied, _) => (
|
||||
"Access denied. Check WebDAV permissions for this directory.".to_string(),
|
||||
false,
|
||||
true
|
||||
),
|
||||
(WebDAVScanFailureType::TooManyItems, _) => (
|
||||
"Directory contains too many items. Consider splitting into subdirectories.".to_string(),
|
||||
false,
|
||||
true
|
||||
),
|
||||
(WebDAVScanFailureType::Timeout, _) if failure.failure_count > 3 => (
|
||||
"Repeated timeouts. Directory may be too large or server is slow.".to_string(),
|
||||
true,
|
||||
false
|
||||
),
|
||||
(WebDAVScanFailureType::NetworkError, _) => (
|
||||
"Network error. Will retry automatically.".to_string(),
|
||||
true,
|
||||
false
|
||||
),
|
||||
(WebDAVScanFailureType::ServerError, _) if failure.http_status_code == Some(404) => (
|
||||
"Directory not found on server. It may have been deleted.".to_string(),
|
||||
false,
|
||||
false
|
||||
),
|
||||
(WebDAVScanFailureType::ServerError, _) => (
|
||||
"Server error. Will retry when server is available.".to_string(),
|
||||
true,
|
||||
false
|
||||
),
|
||||
_ if failure.failure_severity == WebDAVScanFailureSeverity::Critical => (
|
||||
"Critical error that requires manual intervention.".to_string(),
|
||||
false,
|
||||
true
|
||||
),
|
||||
_ if failure.failure_count > 10 => (
|
||||
"Multiple failures. Consider excluding this directory.".to_string(),
|
||||
true,
|
||||
true
|
||||
),
|
||||
_ => (
|
||||
"Temporary error. Will retry automatically.".to_string(),
|
||||
true,
|
||||
false
|
||||
)
|
||||
};
|
||||
|
||||
crate::models::WebDAVFailureDiagnostics {
|
||||
path_length: failure.path_length,
|
||||
directory_depth: failure.directory_depth,
|
||||
estimated_item_count: failure.estimated_item_count,
|
||||
response_time_ms: failure.response_time_ms,
|
||||
response_size_mb,
|
||||
server_type: failure.server_type.clone(),
|
||||
recommended_action,
|
||||
can_retry,
|
||||
user_action_required,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,6 +324,200 @@ pub struct UpdateWebDAVDirectory {
|
|||
pub total_size_bytes: i64,
|
||||
}
|
||||
|
||||
// WebDAV Scan Failure Tracking Models
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebDAVScanFailureType {
|
||||
Timeout,
|
||||
PathTooLong,
|
||||
PermissionDenied,
|
||||
InvalidCharacters,
|
||||
NetworkError,
|
||||
ServerError,
|
||||
XmlParseError,
|
||||
TooManyItems,
|
||||
DepthLimit,
|
||||
SizeLimit,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WebDAVScanFailureType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Timeout => write!(f, "timeout"),
|
||||
Self::PathTooLong => write!(f, "path_too_long"),
|
||||
Self::PermissionDenied => write!(f, "permission_denied"),
|
||||
Self::InvalidCharacters => write!(f, "invalid_characters"),
|
||||
Self::NetworkError => write!(f, "network_error"),
|
||||
Self::ServerError => write!(f, "server_error"),
|
||||
Self::XmlParseError => write!(f, "xml_parse_error"),
|
||||
Self::TooManyItems => write!(f, "too_many_items"),
|
||||
Self::DepthLimit => write!(f, "depth_limit"),
|
||||
Self::SizeLimit => write!(f, "size_limit"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for WebDAVScanFailureType {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"timeout" => Ok(Self::Timeout),
|
||||
"path_too_long" => Ok(Self::PathTooLong),
|
||||
"permission_denied" => Ok(Self::PermissionDenied),
|
||||
"invalid_characters" => Ok(Self::InvalidCharacters),
|
||||
"network_error" => Ok(Self::NetworkError),
|
||||
"server_error" => Ok(Self::ServerError),
|
||||
"xml_parse_error" => Ok(Self::XmlParseError),
|
||||
"too_many_items" => Ok(Self::TooManyItems),
|
||||
"depth_limit" => Ok(Self::DepthLimit),
|
||||
"size_limit" => Ok(Self::SizeLimit),
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
_ => Err(format!("Invalid WebDAV scan failure type: {}", value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebDAVScanFailureSeverity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WebDAVScanFailureSeverity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Low => write!(f, "low"),
|
||||
Self::Medium => write!(f, "medium"),
|
||||
Self::High => write!(f, "high"),
|
||||
Self::Critical => write!(f, "critical"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for WebDAVScanFailureSeverity {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"low" => Ok(Self::Low),
|
||||
"medium" => Ok(Self::Medium),
|
||||
"high" => Ok(Self::High),
|
||||
"critical" => Ok(Self::Critical),
|
||||
_ => Err(format!("Invalid WebDAV scan failure severity: {}", value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow, ToSchema)]
|
||||
pub struct WebDAVScanFailure {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub directory_path: String,
|
||||
|
||||
// Failure tracking
|
||||
#[sqlx(try_from = "String")]
|
||||
pub failure_type: WebDAVScanFailureType,
|
||||
#[sqlx(try_from = "String")]
|
||||
pub failure_severity: WebDAVScanFailureSeverity,
|
||||
pub failure_count: i32,
|
||||
pub consecutive_failures: i32,
|
||||
|
||||
// Timestamps
|
||||
pub first_failure_at: DateTime<Utc>,
|
||||
pub last_failure_at: DateTime<Utc>,
|
||||
pub last_retry_at: Option<DateTime<Utc>>,
|
||||
pub next_retry_at: Option<DateTime<Utc>>,
|
||||
|
||||
// Error details
|
||||
pub error_message: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
pub http_status_code: Option<i32>,
|
||||
|
||||
// Diagnostic information
|
||||
pub response_time_ms: Option<i32>,
|
||||
pub response_size_bytes: Option<i64>,
|
||||
pub path_length: Option<i32>,
|
||||
pub directory_depth: Option<i32>,
|
||||
pub estimated_item_count: Option<i32>,
|
||||
pub server_type: Option<String>,
|
||||
pub server_version: Option<String>,
|
||||
|
||||
// Additional context
|
||||
pub diagnostic_data: Option<serde_json::Value>,
|
||||
|
||||
// User actions
|
||||
pub user_excluded: bool,
|
||||
pub user_notes: Option<String>,
|
||||
|
||||
// Retry strategy
|
||||
pub retry_strategy: Option<String>,
|
||||
pub max_retries: i32,
|
||||
pub retry_delay_seconds: i32,
|
||||
|
||||
// Resolution tracking
|
||||
pub resolved: bool,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
pub resolution_method: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateWebDAVScanFailure {
|
||||
pub user_id: Uuid,
|
||||
pub directory_path: String,
|
||||
pub failure_type: WebDAVScanFailureType,
|
||||
pub error_message: String,
|
||||
pub error_code: Option<String>,
|
||||
pub http_status_code: Option<i32>,
|
||||
pub response_time_ms: Option<i32>,
|
||||
pub response_size_bytes: Option<i64>,
|
||||
pub diagnostic_data: Option<serde_json::Value>,
|
||||
pub server_type: Option<String>,
|
||||
pub server_version: Option<String>,
|
||||
pub estimated_item_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct WebDAVScanFailureResponse {
|
||||
pub id: Uuid,
|
||||
pub directory_path: String,
|
||||
pub failure_type: WebDAVScanFailureType,
|
||||
pub failure_severity: WebDAVScanFailureSeverity,
|
||||
pub failure_count: i32,
|
||||
pub consecutive_failures: i32,
|
||||
pub first_failure_at: DateTime<Utc>,
|
||||
pub last_failure_at: DateTime<Utc>,
|
||||
pub next_retry_at: Option<DateTime<Utc>>,
|
||||
pub error_message: Option<String>,
|
||||
pub http_status_code: Option<i32>,
|
||||
pub user_excluded: bool,
|
||||
pub user_notes: Option<String>,
|
||||
pub resolved: bool,
|
||||
pub diagnostic_summary: WebDAVFailureDiagnostics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct WebDAVFailureDiagnostics {
|
||||
pub path_length: Option<i32>,
|
||||
pub directory_depth: Option<i32>,
|
||||
pub estimated_item_count: Option<i32>,
|
||||
pub response_time_ms: Option<i32>,
|
||||
pub response_size_mb: Option<f64>,
|
||||
pub server_type: Option<String>,
|
||||
pub recommended_action: String,
|
||||
pub can_retry: bool,
|
||||
pub user_action_required: bool,
|
||||
}
|
||||
|
||||
// Notification-related structs
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Notification {
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ pub mod settings;
|
|||
pub mod sources;
|
||||
pub mod users;
|
||||
pub mod webdav;
|
||||
pub mod webdav_scan_failures;
|
||||
|
|
@ -30,6 +30,12 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/sync-status", get(get_webdav_sync_status))
|
||||
.route("/start-sync", post(start_webdav_sync))
|
||||
.route("/cancel-sync", post(cancel_webdav_sync))
|
||||
// Scan failure tracking endpoints
|
||||
.route("/scan-failures", get(crate::routes::webdav_scan_failures::list_scan_failures))
|
||||
.route("/scan-failures/:id", get(crate::routes::webdav_scan_failures::get_scan_failure))
|
||||
.route("/scan-failures/:id/retry", post(crate::routes::webdav_scan_failures::retry_scan_failure))
|
||||
.route("/scan-failures/:id/exclude", post(crate::routes::webdav_scan_failures::exclude_scan_failure))
|
||||
.route("/scan-failures/retry-candidates", get(crate::routes::webdav_scan_failures::get_retry_candidates))
|
||||
}
|
||||
|
||||
async fn get_user_webdav_config(state: &Arc<AppState>, user_id: uuid::Uuid) -> Result<WebDAVConfig, StatusCode> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::{WebDAVScanFailure, WebDAVScanFailureResponse};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RetryFailureRequest {
|
||||
/// Optional notes about why the retry is being attempted
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ExcludeFailureRequest {
|
||||
/// User notes about why the directory is being excluded
|
||||
pub notes: Option<String>,
|
||||
/// Whether to permanently exclude (true) or just temporarily (false)
|
||||
pub permanent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScanFailureStatsResponse {
|
||||
pub active_failures: i64,
|
||||
pub resolved_failures: i64,
|
||||
pub excluded_directories: i64,
|
||||
pub critical_failures: i64,
|
||||
pub high_failures: i64,
|
||||
pub medium_failures: i64,
|
||||
pub low_failures: i64,
|
||||
pub ready_for_retry: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ScanFailuresListResponse {
|
||||
pub failures: Vec<WebDAVScanFailureResponse>,
|
||||
pub stats: ScanFailureStatsResponse,
|
||||
}
|
||||
|
||||
/// GET /api/webdav/scan-failures - List all scan failures for the authenticated user
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/webdav/scan-failures",
|
||||
responses(
|
||||
(status = 200, description = "List of scan failures", body = ScanFailuresListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "WebDAV"
|
||||
)]
|
||||
pub async fn list_scan_failures(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<ScanFailuresListResponse>, StatusCode> {
|
||||
info!(
|
||||
"📋 Listing WebDAV scan failures for user: {}",
|
||||
auth_user.user.id
|
||||
);
|
||||
|
||||
// Get failures from database
|
||||
let failures = state.db.get_scan_failures(auth_user.user.id, false).await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get scan failures: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Get statistics
|
||||
let stats = state.db.get_scan_failure_stats(auth_user.user.id).await
|
||||
.map_err(|e| {
|
||||
error!("Failed to get scan failure stats: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Convert failures to response format with diagnostics
|
||||
let mut failure_responses = Vec::new();
|
||||
for failure in failures {
|
||||
if let Ok(Some(response)) = state.db.get_scan_failure_with_diagnostics(auth_user.user.id, failure.id).await {
|
||||
failure_responses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert stats to response format
|
||||
let stats_response = ScanFailureStatsResponse {
|
||||
active_failures: stats.get("active_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
resolved_failures: stats.get("resolved_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
excluded_directories: stats.get("excluded_directories")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
critical_failures: stats.get("critical_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
high_failures: stats.get("high_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
medium_failures: stats.get("medium_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
low_failures: stats.get("low_failures")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
ready_for_retry: stats.get("ready_for_retry")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
info!(
|
||||
"Found {} active scan failures for user",
|
||||
failure_responses.len()
|
||||
);
|
||||
|
||||
Ok(Json(ScanFailuresListResponse {
|
||||
failures: failure_responses,
|
||||
stats: stats_response,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/webdav/scan-failures/{id} - Get detailed information about a specific scan failure
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/webdav/scan-failures/{id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Scan failure ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Scan failure details", body = WebDAVScanFailureResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Failure not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "WebDAV"
|
||||
)]
|
||||
pub async fn get_scan_failure(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(failure_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
info!(
|
||||
"🔍 Getting scan failure details for ID: {} (user: {})",
|
||||
failure_id, auth_user.user.id
|
||||
);
|
||||
|
||||
match state.db.get_scan_failure_with_diagnostics(auth_user.user.id, failure_id).await {
|
||||
Ok(Some(failure)) => {
|
||||
info!("Found scan failure: {}", failure.directory_path);
|
||||
Ok(Json(serde_json::to_value(failure).unwrap()))
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("Scan failure not found: {}", failure_id);
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get scan failure: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/webdav/scan-failures/{id}/retry - Reset and retry a failed scan
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/webdav/scan-failures/{id}/retry",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Scan failure ID")
|
||||
),
|
||||
request_body = RetryFailureRequest,
|
||||
responses(
|
||||
(status = 200, description = "Failure reset for retry"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Failure not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "WebDAV"
|
||||
)]
|
||||
pub async fn retry_scan_failure(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(failure_id): Path<Uuid>,
|
||||
Json(request): Json<RetryFailureRequest>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
info!(
|
||||
"🔄 Retrying scan failure {} for user: {}",
|
||||
failure_id, auth_user.user.id
|
||||
);
|
||||
|
||||
// First get the failure to find the directory path
|
||||
let failure = match state.db.get_scan_failure_with_diagnostics(auth_user.user.id, failure_id).await {
|
||||
Ok(Some(f)) => f,
|
||||
Ok(None) => {
|
||||
warn!("Scan failure not found for retry: {}", failure_id);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get scan failure for retry: {}", e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset the failure for retry
|
||||
match state.db.reset_scan_failure(auth_user.user.id, &failure.directory_path).await {
|
||||
Ok(success) => {
|
||||
if success {
|
||||
info!(
|
||||
"✅ Reset scan failure for directory '{}' - ready for retry",
|
||||
failure.directory_path
|
||||
);
|
||||
|
||||
// TODO: Trigger an immediate scan of this directory
|
||||
// This would integrate with the WebDAV scheduler
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": format!("Directory '{}' has been reset and will be retried", failure.directory_path),
|
||||
"directory_path": failure.directory_path
|
||||
})))
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to reset scan failure for directory '{}'",
|
||||
failure.directory_path
|
||||
);
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to reset scan failure: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/webdav/scan-failures/{id}/exclude - Mark a directory as permanently excluded
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/webdav/scan-failures/{id}/exclude",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Scan failure ID")
|
||||
),
|
||||
request_body = ExcludeFailureRequest,
|
||||
responses(
|
||||
(status = 200, description = "Directory excluded from scanning"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Failure not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "WebDAV"
|
||||
)]
|
||||
pub async fn exclude_scan_failure(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(failure_id): Path<Uuid>,
|
||||
Json(request): Json<ExcludeFailureRequest>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
info!(
|
||||
"🚫 Excluding scan failure {} for user: {}",
|
||||
failure_id, auth_user.user.id
|
||||
);
|
||||
|
||||
// First get the failure to find the directory path
|
||||
let failure = match state.db.get_scan_failure_with_diagnostics(auth_user.user.id, failure_id).await {
|
||||
Ok(Some(f)) => f,
|
||||
Ok(None) => {
|
||||
warn!("Scan failure not found for exclusion: {}", failure_id);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get scan failure for exclusion: {}", e);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
// Exclude the directory
|
||||
match state.db.exclude_directory_from_scan(
|
||||
auth_user.user.id,
|
||||
&failure.directory_path,
|
||||
request.notes.as_deref(),
|
||||
).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"✅ Excluded directory '{}' from scanning",
|
||||
failure.directory_path
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": format!("Directory '{}' has been excluded from scanning", failure.directory_path),
|
||||
"directory_path": failure.directory_path,
|
||||
"permanent": request.permanent
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to exclude directory from scanning: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/webdav/scan-failures/retry-candidates - Get directories ready for retry
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/webdav/scan-failures/retry-candidates",
|
||||
responses(
|
||||
(status = 200, description = "List of directories ready for retry", body = Vec<String>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "WebDAV"
|
||||
)]
|
||||
pub async fn get_retry_candidates(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
info!(
|
||||
"🔍 Getting retry candidates for user: {}",
|
||||
auth_user.user.id
|
||||
);
|
||||
|
||||
match state.db.get_directories_ready_for_retry(auth_user.user.id).await {
|
||||
Ok(directories) => {
|
||||
info!(
|
||||
"Found {} directories ready for retry",
|
||||
directories.len()
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"directories": directories,
|
||||
"count": directories.len()
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get retry candidates: {}", e);
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{
|
||||
CreateWebDAVScanFailure, WebDAVScanFailureType, WebDAVScanFailure,
|
||||
WebDAVScanFailureResponse, WebDAVFailureDiagnostics,
|
||||
};
|
||||
|
||||
/// Helper for tracking and analyzing WebDAV scan failures
|
||||
pub struct WebDAVErrorTracker {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl WebDAVErrorTracker {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Analyze an error and record it as a scan failure
|
||||
pub async fn track_scan_error(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
error: &anyhow::Error,
|
||||
response_time: Option<Duration>,
|
||||
response_size: Option<usize>,
|
||||
server_type: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let failure_type = self.classify_error_type(error);
|
||||
let http_status = self.extract_http_status(error);
|
||||
|
||||
// Build diagnostic data
|
||||
let mut diagnostic_data = serde_json::json!({
|
||||
"error_chain": format!("{:?}", error),
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Add stack trace if available
|
||||
if let Some(backtrace) = error.backtrace().to_string().as_str() {
|
||||
if !backtrace.is_empty() {
|
||||
diagnostic_data["backtrace"] = serde_json::json!(backtrace);
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate item count from error message if possible
|
||||
let estimated_items = self.estimate_item_count_from_error(error);
|
||||
|
||||
let failure = CreateWebDAVScanFailure {
|
||||
user_id,
|
||||
directory_path: directory_path.to_string(),
|
||||
failure_type,
|
||||
error_message: error.to_string(),
|
||||
error_code: self.extract_error_code(error),
|
||||
http_status_code: http_status,
|
||||
response_time_ms: response_time.map(|d| d.as_millis() as i32),
|
||||
response_size_bytes: response_size.map(|s| s as i64),
|
||||
diagnostic_data: Some(diagnostic_data),
|
||||
server_type: server_type.map(|s| s.to_string()),
|
||||
server_version: None, // Could be extracted from headers if available
|
||||
estimated_item_count: estimated_items,
|
||||
};
|
||||
|
||||
match self.db.record_scan_failure(&failure).await {
|
||||
Ok(failure_id) => {
|
||||
warn!(
|
||||
"📝 Recorded scan failure for directory '{}': {} (ID: {})",
|
||||
directory_path, error, failure_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to record scan failure for directory '{}': {}",
|
||||
directory_path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a directory should be skipped due to previous failures
|
||||
pub async fn should_skip_directory(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
) -> Result<bool> {
|
||||
match self.db.is_known_failure(user_id, directory_path).await {
|
||||
Ok(should_skip) => {
|
||||
if should_skip {
|
||||
debug!(
|
||||
"⏭️ Skipping directory '{}' due to previous failures",
|
||||
directory_path
|
||||
);
|
||||
}
|
||||
Ok(should_skip)
|
||||
}
|
||||
Err(e) => {
|
||||
// If we can't check, err on the side of trying to scan
|
||||
warn!(
|
||||
"Failed to check failure status for directory '{}': {}",
|
||||
directory_path, e
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a directory scan as successful (resolves any previous failures)
|
||||
pub async fn mark_scan_successful(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
) -> Result<()> {
|
||||
match self.db.resolve_scan_failure(user_id, directory_path, "successful_scan").await {
|
||||
Ok(resolved) => {
|
||||
if resolved {
|
||||
info!(
|
||||
"✅ Resolved previous scan failures for directory '{}'",
|
||||
directory_path
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Failed to mark scan as successful for directory '{}': {}",
|
||||
directory_path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get directories that are ready for retry
|
||||
pub async fn get_retry_candidates(&self, user_id: Uuid) -> Result<Vec<String>> {
|
||||
self.db.get_directories_ready_for_retry(user_id).await
|
||||
}
|
||||
|
||||
/// Classify the type of error based on error message and context
|
||||
fn classify_error_type(&self, error: &anyhow::Error) -> WebDAVScanFailureType {
|
||||
let error_str = error.to_string().to_lowercase();
|
||||
|
||||
// Check for specific error patterns
|
||||
if error_str.contains("timeout") || error_str.contains("timed out") {
|
||||
WebDAVScanFailureType::Timeout
|
||||
} else if error_str.contains("name too long") || error_str.contains("path too long") {
|
||||
WebDAVScanFailureType::PathTooLong
|
||||
} else if error_str.contains("permission denied") || error_str.contains("forbidden") || error_str.contains("401") || error_str.contains("403") {
|
||||
WebDAVScanFailureType::PermissionDenied
|
||||
} else if error_str.contains("invalid character") || error_str.contains("illegal character") {
|
||||
WebDAVScanFailureType::InvalidCharacters
|
||||
} else if error_str.contains("connection refused") || error_str.contains("network") || error_str.contains("dns") {
|
||||
WebDAVScanFailureType::NetworkError
|
||||
} else if error_str.contains("500") || error_str.contains("502") || error_str.contains("503") || error_str.contains("504") {
|
||||
WebDAVScanFailureType::ServerError
|
||||
} else if error_str.contains("xml") || error_str.contains("parse") || error_str.contains("malformed") {
|
||||
WebDAVScanFailureType::XmlParseError
|
||||
} else if error_str.contains("too many") || error_str.contains("limit exceeded") {
|
||||
WebDAVScanFailureType::TooManyItems
|
||||
} else if error_str.contains("depth") || error_str.contains("nested") {
|
||||
WebDAVScanFailureType::DepthLimit
|
||||
} else if error_str.contains("size") || error_str.contains("too large") {
|
||||
WebDAVScanFailureType::SizeLimit
|
||||
} else if error_str.contains("404") || error_str.contains("not found") {
|
||||
WebDAVScanFailureType::ServerError // Will be further classified by HTTP status
|
||||
} else {
|
||||
WebDAVScanFailureType::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract HTTP status code from error if present
|
||||
fn extract_http_status(&self, error: &anyhow::Error) -> Option<i32> {
|
||||
let error_str = error.to_string();
|
||||
|
||||
// Look for common HTTP status code patterns
|
||||
if error_str.contains("404") {
|
||||
Some(404)
|
||||
} else if error_str.contains("401") {
|
||||
Some(401)
|
||||
} else if error_str.contains("403") {
|
||||
Some(403)
|
||||
} else if error_str.contains("500") {
|
||||
Some(500)
|
||||
} else if error_str.contains("502") {
|
||||
Some(502)
|
||||
} else if error_str.contains("503") {
|
||||
Some(503)
|
||||
} else if error_str.contains("504") {
|
||||
Some(504)
|
||||
} else if error_str.contains("405") {
|
||||
Some(405)
|
||||
} else {
|
||||
// Try to extract any 3-digit number that looks like an HTTP status
|
||||
let re = regex::Regex::new(r"\b([4-5]\d{2})\b").ok()?;
|
||||
re.captures(&error_str)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse::<i32>().ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract error code if present (e.g., system error codes)
|
||||
fn extract_error_code(&self, error: &anyhow::Error) -> Option<String> {
|
||||
let error_str = error.to_string();
|
||||
|
||||
// Look for common error code patterns
|
||||
if let Some(caps) = regex::Regex::new(r"(?i)error[:\s]+([A-Z0-9_]+)")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(&error_str))
|
||||
{
|
||||
return caps.get(1).map(|m| m.as_str().to_string());
|
||||
}
|
||||
|
||||
// Look for OS error codes
|
||||
if let Some(caps) = regex::Regex::new(r"(?i)os error (\d+)")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(&error_str))
|
||||
{
|
||||
return caps.get(1).map(|m| format!("OS_{}", m.as_str()));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to estimate item count from error message
|
||||
fn estimate_item_count_from_error(&self, error: &anyhow::Error) -> Option<i32> {
|
||||
let error_str = error.to_string();
|
||||
|
||||
// Look for patterns like "1000 items", "contains 500 files", etc.
|
||||
if let Some(caps) = regex::Regex::new(r"(\d+)\s*(?:items?|files?|directories|folders?|entries)")
|
||||
.ok()
|
||||
.and_then(|re| re.captures(&error_str))
|
||||
{
|
||||
return caps.get(1)
|
||||
.and_then(|m| m.as_str().parse::<i32>().ok());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a user-friendly error message with recommendations
|
||||
pub fn build_user_friendly_error_message(
|
||||
&self,
|
||||
failure: &WebDAVScanFailure,
|
||||
) -> String {
|
||||
use crate::models::WebDAVScanFailureType;
|
||||
|
||||
let base_message = match &failure.failure_type {
|
||||
WebDAVScanFailureType::Timeout => {
|
||||
format!(
|
||||
"The directory '{}' is taking too long to scan. This might be due to a large number of files or slow server response.",
|
||||
failure.directory_path
|
||||
)
|
||||
}
|
||||
WebDAVScanFailureType::PathTooLong => {
|
||||
format!(
|
||||
"The path '{}' exceeds system limits ({}+ characters). Consider shortening directory names.",
|
||||
failure.directory_path,
|
||||
failure.path_length.unwrap_or(0)
|
||||
)
|
||||
}
|
||||
WebDAVScanFailureType::PermissionDenied => {
|
||||
format!(
|
||||
"Access denied to '{}'. Please check your WebDAV permissions.",
|
||||
failure.directory_path
|
||||
)
|
||||
}
|
||||
WebDAVScanFailureType::TooManyItems => {
|
||||
format!(
|
||||
"Directory '{}' contains too many items (estimated: {}). Consider organizing into subdirectories.",
|
||||
failure.directory_path,
|
||||
failure.estimated_item_count.unwrap_or(0)
|
||||
)
|
||||
}
|
||||
WebDAVScanFailureType::ServerError if failure.http_status_code == Some(404) => {
|
||||
format!(
|
||||
"Directory '{}' was not found on the server. It may have been deleted or moved.",
|
||||
failure.directory_path
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
format!(
|
||||
"Failed to scan directory '{}': {}",
|
||||
failure.directory_path,
|
||||
failure.error_message.as_ref().unwrap_or(&"Unknown error".to_string())
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Add retry information if applicable
|
||||
let retry_info = if failure.consecutive_failures > 1 {
|
||||
format!(
|
||||
" This has failed {} times.",
|
||||
failure.consecutive_failures
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Add next retry time if scheduled
|
||||
let next_retry = if let Some(next_retry_at) = failure.next_retry_at {
|
||||
if !failure.user_excluded && !failure.resolved {
|
||||
let duration = next_retry_at.signed_duration_since(chrono::Utc::now());
|
||||
if duration.num_seconds() > 0 {
|
||||
format!(
|
||||
" Will retry in {} minutes.",
|
||||
duration.num_minutes().max(1)
|
||||
)
|
||||
} else {
|
||||
" Ready for retry.".to_string()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!("{}{}{}", base_message, retry_info, next_retry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for WebDAV service to add error tracking capabilities
|
||||
pub trait WebDAVServiceErrorTracking {
|
||||
/// Track an error that occurred during scanning
|
||||
async fn track_scan_error(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
error: anyhow::Error,
|
||||
scan_duration: Duration,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Check if directory should be skipped
|
||||
async fn should_skip_for_failures(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
) -> Result<bool>;
|
||||
|
||||
/// Mark directory scan as successful
|
||||
async fn mark_scan_success(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
directory_path: &str,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ pub mod config;
|
|||
pub mod service;
|
||||
pub mod smart_sync;
|
||||
pub mod progress_shim; // Backward compatibility shim for simplified progress tracking
|
||||
pub mod error_tracking; // WebDAV scan failure tracking and analysis
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use config::{WebDAVConfig, RetryConfig, ConcurrencyConfig};
|
||||
|
|
|
|||
Loading…
Reference in New Issue