feat(server/client): fix thumbnails and quick search
This commit is contained in:
parent
d51f2793e9
commit
6f3aa771c0
|
|
@ -643,8 +643,6 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
width: 0,
|
|
||||||
minWidth: 0,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -707,8 +705,6 @@ const GlobalSearchBar: React.FC<GlobalSearchBarProps> = ({ sx, ...props }) => {
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
width: 0,
|
|
||||||
minWidth: 0,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
|
import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon,
|
||||||
CloudSync as CloudSyncIcon, Folder as FolderIcon,
|
CloudSync as CloudSyncIcon, Folder as FolderIcon,
|
||||||
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon } from '@mui/icons-material';
|
Assessment as AssessmentIcon, PlayArrow as PlayArrowIcon,
|
||||||
|
Pause as PauseIcon, Stop as StopIcon } from '@mui/icons-material';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import api from '../services/api';
|
import api, { queueService } from '../services/api';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -906,6 +907,10 @@ const SettingsPage: React.FC = () => {
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OCR Admin Controls State
|
||||||
|
const [ocrStatus, setOcrStatus] = useState<{ is_paused: boolean; status: 'paused' | 'running' } | null>(null);
|
||||||
|
const [ocrActionLoading, setOcrActionLoading] = useState(false);
|
||||||
|
|
||||||
const ocrLanguages: OcrLanguage[] = [
|
const ocrLanguages: OcrLanguage[] = [
|
||||||
{ code: 'eng', name: 'English' },
|
{ code: 'eng', name: 'English' },
|
||||||
|
|
@ -928,6 +933,7 @@ const SettingsPage: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
fetchOcrStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async (): Promise<void> => {
|
const fetchSettings = async (): Promise<void> => {
|
||||||
|
|
@ -1108,6 +1114,52 @@ const SettingsPage: React.FC = () => {
|
||||||
handleSettingsChange('searchResultsPerPage', event.target.value);
|
handleSettingsChange('searchResultsPerPage', event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchOcrStatus = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await queueService.getOcrStatus();
|
||||||
|
setOcrStatus(response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching OCR status:', error);
|
||||||
|
// Don't show error for OCR status since it might not be available for non-admin users
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseOcr = async (): Promise<void> => {
|
||||||
|
setOcrActionLoading(true);
|
||||||
|
try {
|
||||||
|
await queueService.pauseOcr();
|
||||||
|
showSnackbar('OCR processing paused successfully', 'success');
|
||||||
|
fetchOcrStatus(); // Refresh status
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error pausing OCR:', error);
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
showSnackbar('Admin access required to pause OCR processing', 'error');
|
||||||
|
} else {
|
||||||
|
showSnackbar('Failed to pause OCR processing', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setOcrActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeOcr = async (): Promise<void> => {
|
||||||
|
setOcrActionLoading(true);
|
||||||
|
try {
|
||||||
|
await queueService.resumeOcr();
|
||||||
|
showSnackbar('OCR processing resumed successfully', 'success');
|
||||||
|
fetchOcrStatus(); // Refresh status
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error resuming OCR:', error);
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
showSnackbar('Admin access required to resume OCR processing', 'error');
|
||||||
|
} else {
|
||||||
|
showSnackbar('Failed to resume OCR processing', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setOcrActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
<Typography variant="h4" sx={{ mb: 4 }}>
|
<Typography variant="h4" sx={{ mb: 4 }}>
|
||||||
|
|
@ -1196,6 +1248,69 @@ const SettingsPage: React.FC = () => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Admin OCR Controls */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
<StopIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||||
|
OCR Processing Controls (Admin Only)
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||||
|
Control OCR processing to manage CPU usage and allow users to use the application without performance impact.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant={ocrStatus?.is_paused ? "outlined" : "contained"}
|
||||||
|
color={ocrStatus?.is_paused ? "success" : "warning"}
|
||||||
|
startIcon={ocrActionLoading ? <CircularProgress size={16} /> :
|
||||||
|
(ocrStatus?.is_paused ? <PlayArrowIcon /> : <PauseIcon />)}
|
||||||
|
onClick={ocrStatus?.is_paused ? handleResumeOcr : handlePauseOcr}
|
||||||
|
disabled={ocrActionLoading || loading}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{ocrActionLoading ? 'Processing...' :
|
||||||
|
ocrStatus?.is_paused ? 'Resume OCR Processing' : 'Pause OCR Processing'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
{ocrStatus && (
|
||||||
|
<Box>
|
||||||
|
<Chip
|
||||||
|
label={`OCR Status: ${ocrStatus.status.toUpperCase()}`}
|
||||||
|
color={ocrStatus.is_paused ? "warning" : "success"}
|
||||||
|
variant="outlined"
|
||||||
|
icon={ocrStatus.is_paused ? <PauseIcon /> : <PlayArrowIcon />}
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'text.secondary' }}>
|
||||||
|
{ocrStatus.is_paused
|
||||||
|
? 'OCR processing is paused. No new jobs will be processed.'
|
||||||
|
: 'OCR processing is active. Documents will be processed automatically.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{ocrStatus?.is_paused && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<strong>OCR Processing Paused</strong><br />
|
||||||
|
New documents will not be processed for OCR text extraction until processing is resumed.
|
||||||
|
Users can still upload and view documents, but search functionality may be limited.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,16 @@ export const documentService = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OcrStatusResponse {
|
||||||
|
is_paused: boolean
|
||||||
|
status: 'paused' | 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrActionResponse {
|
||||||
|
status: 'paused' | 'resumed'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export const queueService = {
|
export const queueService = {
|
||||||
getStats: () => {
|
getStats: () => {
|
||||||
return api.get<QueueStats>('/queue/stats')
|
return api.get<QueueStats>('/queue/stats')
|
||||||
|
|
@ -192,4 +202,16 @@ export const queueService = {
|
||||||
requeueFailed: () => {
|
requeueFailed: () => {
|
||||||
return api.post('/queue/requeue-failed')
|
return api.post('/queue/requeue-failed')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getOcrStatus: () => {
|
||||||
|
return api.get<OcrStatusResponse>('/queue/status')
|
||||||
|
},
|
||||||
|
|
||||||
|
pauseOcr: () => {
|
||||||
|
return api.post<OcrActionResponse>('/queue/pause')
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeOcr: () => {
|
||||||
|
return api.post<OcrActionResponse>('/queue/resume')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -152,9 +152,22 @@ impl FileService {
|
||||||
let img = image::load_from_memory(file_data)?;
|
let img = image::load_from_memory(file_data)?;
|
||||||
let thumbnail = img.resize(200, 200, FilterType::Lanczos3);
|
let thumbnail = img.resize(200, 200, FilterType::Lanczos3);
|
||||||
|
|
||||||
|
// Convert to RGB if the image has an alpha channel (RGBA)
|
||||||
|
// JPEG doesn't support transparency, so we need to remove the alpha channel
|
||||||
|
let rgb_thumbnail = match thumbnail {
|
||||||
|
image::DynamicImage::ImageRgba8(_) => {
|
||||||
|
// Convert RGBA to RGB by compositing against a white background
|
||||||
|
let rgb_img = image::DynamicImage::ImageRgb8(
|
||||||
|
thumbnail.to_rgb8()
|
||||||
|
);
|
||||||
|
rgb_img
|
||||||
|
},
|
||||||
|
_ => thumbnail, // Already RGB or other compatible format
|
||||||
|
};
|
||||||
|
|
||||||
let mut buffer = Vec::new();
|
let mut buffer = Vec::new();
|
||||||
let mut cursor = std::io::Cursor::new(&mut buffer);
|
let mut cursor = std::io::Cursor::new(&mut buffer);
|
||||||
thumbnail.write_to(&mut cursor, ImageFormat::Jpeg)?;
|
rgb_thumbnail.write_to(&mut cursor, ImageFormat::Jpeg)?;
|
||||||
|
|
||||||
Ok(buffer)
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, PgPool, Row};
|
use sqlx::{FromRow, PgPool, Row};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tokio::time::{sleep, Duration};
|
use tokio::time::{sleep, Duration};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
@ -45,6 +46,7 @@ pub struct OcrQueueService {
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
transaction_manager: DocumentTransactionManager,
|
transaction_manager: DocumentTransactionManager,
|
||||||
processing_throttler: Arc<RequestThrottler>,
|
processing_throttler: Arc<RequestThrottler>,
|
||||||
|
is_paused: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OcrQueueService {
|
impl OcrQueueService {
|
||||||
|
|
@ -67,6 +69,7 @@ impl OcrQueueService {
|
||||||
worker_id,
|
worker_id,
|
||||||
transaction_manager,
|
transaction_manager,
|
||||||
processing_throttler,
|
processing_throttler,
|
||||||
|
is_paused: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,6 +404,23 @@ impl OcrQueueService {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pause OCR processing
|
||||||
|
pub fn pause(&self) {
|
||||||
|
self.is_paused.store(true, Ordering::SeqCst);
|
||||||
|
info!("OCR processing paused for worker {}", self.worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume OCR processing
|
||||||
|
pub fn resume(&self) {
|
||||||
|
self.is_paused.store(false, Ordering::SeqCst);
|
||||||
|
info!("OCR processing resumed for worker {}", self.worker_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if OCR processing is paused
|
||||||
|
pub fn is_paused(&self) -> bool {
|
||||||
|
self.is_paused.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the worker loop
|
/// Start the worker loop
|
||||||
pub async fn start_worker(self: Arc<Self>) -> Result<()> {
|
pub async fn start_worker(self: Arc<Self>) -> Result<()> {
|
||||||
let semaphore = Arc::new(Semaphore::new(self.max_concurrent_jobs));
|
let semaphore = Arc::new(Semaphore::new(self.max_concurrent_jobs));
|
||||||
|
|
@ -412,6 +432,13 @@ impl OcrQueueService {
|
||||||
);
|
);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Check if processing is paused
|
||||||
|
if self.is_paused() {
|
||||||
|
info!("OCR processing is paused, waiting...");
|
||||||
|
sleep(Duration::from_secs(5)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for items to process
|
// Check for items to process
|
||||||
match self.dequeue().await {
|
match self.dequeue().await {
|
||||||
Ok(Some(item)) => {
|
Ok(Some(item)) => {
|
||||||
|
|
|
||||||
|
|
@ -360,8 +360,9 @@ async fn get_document_thumbnail(
|
||||||
.body(thumbnail_data.into())
|
.body(thumbnail_data.into())
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
// Return a placeholder thumbnail or 404
|
// Log the error for debugging
|
||||||
|
tracing::error!("Failed to generate thumbnail for document {}: {}", document_id, e);
|
||||||
Err(StatusCode::NOT_FOUND)
|
Err(StatusCode::NOT_FOUND)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,23 @@ use axum::{
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{auth::AuthUser, ocr_queue::OcrQueueService, AppState};
|
use crate::{auth::AuthUser, ocr_queue::OcrQueueService, AppState, models::UserRole};
|
||||||
|
|
||||||
|
fn require_admin(auth_user: &AuthUser) -> Result<(), StatusCode> {
|
||||||
|
if auth_user.user.role != UserRole::Admin {
|
||||||
|
Err(StatusCode::FORBIDDEN)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<AppState>> {
|
pub fn router() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/stats", get(get_queue_stats))
|
.route("/stats", get(get_queue_stats))
|
||||||
.route("/requeue-failed", post(requeue_failed))
|
.route("/requeue-failed", post(requeue_failed))
|
||||||
|
.route("/pause", post(pause_ocr_processing))
|
||||||
|
.route("/resume", post(resume_ocr_processing))
|
||||||
|
.route("/status", get(get_ocr_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -75,4 +86,85 @@ async fn requeue_failed(
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"requeued_count": count,
|
"requeued_count": count,
|
||||||
})))
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/queue/pause",
|
||||||
|
tag = "queue",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "OCR processing paused successfully"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin access required")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn pause_ocr_processing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
require_admin(&auth_user)?;
|
||||||
|
|
||||||
|
state.queue_service.pause();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "paused",
|
||||||
|
"message": "OCR processing has been paused"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/queue/resume",
|
||||||
|
tag = "queue",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "OCR processing resumed successfully"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin access required")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn resume_ocr_processing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
require_admin(&auth_user)?;
|
||||||
|
|
||||||
|
state.queue_service.resume();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "resumed",
|
||||||
|
"message": "OCR processing has been resumed"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/queue/status",
|
||||||
|
tag = "queue",
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "OCR processing status"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 403, description = "Forbidden - Admin access required")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_ocr_status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
require_admin(&auth_user)?;
|
||||||
|
|
||||||
|
let is_paused = state.queue_service.is_paused();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"is_paused": is_paused,
|
||||||
|
"status": if is_paused { "paused" } else { "running" }
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue