From 6e0a77fae025589f2f92e9436ed5329b3573f65b Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Fri, 6 Mar 2026 11:42:41 +0100 Subject: [PATCH] feat(monitoring): T23 - Surveillance automatique des libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout scheduler dans l'indexer (vérifie toutes les minutes) - Migration 0004: colonnes monitor_enabled, scan_mode, next_scan_at - API: GET /libraries avec champs monitoring - API: PATCH /libraries/:id/monitoring pour configuration - Composant MonitoringForm (client) avec checkbox et select - Badge Auto/Manual avec couleurs différentes - Affichage temps restant avant prochain scan - Proxy route /api/libraries/:id/monitoring Le scheduler crée automatiquement des jobs quand next_scan_at <= NOW() --- apps/api/src/libraries.rs | 90 ++++++++++++++++++- apps/api/src/main.rs | 1 + apps/api/src/openapi.rs | 2 + .../app/components/MonitoringForm.tsx | 62 +++++++++++++ apps/backoffice/app/globals.css | 63 +++++++++++++ apps/backoffice/lib/api.ts | 10 +++ apps/indexer/src/main.rs | 75 ++++++++++++++++ 7 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 apps/backoffice/app/components/MonitoringForm.tsx diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 18c4ae9..36dae6c 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -16,6 +16,9 @@ pub struct LibraryResponse { pub root_path: String, pub enabled: bool, pub book_count: i64, + pub monitor_enabled: bool, + pub scan_mode: String, + pub next_scan_at: Option>, } #[derive(Deserialize, ToSchema)] @@ -40,7 +43,7 @@ pub struct CreateLibraryRequest { )] pub async fn list_libraries(State(state): State) -> Result>, ApiError> { let rows = sqlx::query( - "SELECT l.id, l.name, l.root_path, l.enabled, + "SELECT l.id, l.name, l.root_path, l.enabled, l.monitor_enabled, l.scan_mode, l.next_scan_at, (SELECT COUNT(*) FROM books b WHERE b.library_id = l.id) as book_count FROM libraries l ORDER BY l.created_at DESC" ) @@ -55,6 +58,9 @@ pub async fn list_libraries(State(state): State) -> Result, + AxumPath(library_id): AxumPath, + Json(input): Json, +) -> Result, ApiError> { + // Validate scan_mode + let valid_modes = ["manual", "hourly", "daily", "weekly"]; + if !valid_modes.contains(&input.scan_mode.as_str()) { + return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly")); + } + + // Calculate next_scan_at if monitoring is enabled + let next_scan_at = if input.monitor_enabled { + let interval_minutes = match input.scan_mode.as_str() { + "hourly" => 60, + "daily" => 1440, + "weekly" => 10080, + _ => 1440, + }; + Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes)) + } else { + None + }; + + let result = sqlx::query( + "UPDATE libraries SET monitor_enabled = $2, scan_mode = $3, next_scan_at = $4 WHERE id = $1 RETURNING id, name, root_path, enabled, monitor_enabled, scan_mode, next_scan_at" + ) + .bind(library_id) + .bind(input.monitor_enabled) + .bind(input.scan_mode) + .bind(next_scan_at) + .fetch_optional(&state.pool) + .await?; + + let Some(row) = result else { + return Err(ApiError::not_found("library not found")); + }; + + let book_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM books WHERE library_id = $1") + .bind(library_id) + .fetch_one(&state.pool) + .await?; + + Ok(Json(LibraryResponse { + id: row.get("id"), + name: row.get("name"), + root_path: row.get("root_path"), + enabled: row.get("enabled"), + book_count, + monitor_enabled: row.get("monitor_enabled"), + scan_mode: row.get("scan_mode"), + next_scan_at: row.get("next_scan_at"), + })) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index b4318bc..f4ca1d3 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -96,6 +96,7 @@ async fn main() -> anyhow::Result<()> { .route("/libraries", get(libraries::list_libraries).post(libraries::create_library)) .route("/libraries/:id", delete(libraries::delete_library)) .route("/libraries/:id/scan", axum::routing::post(libraries::scan_library)) + .route("/libraries/:id/monitoring", axum::routing::patch(libraries::update_monitoring)) .route("/index/rebuild", axum::routing::post(index_jobs::enqueue_rebuild)) .route("/index/status", get(index_jobs::list_index_jobs)) .route("/index/jobs/active", get(index_jobs::get_active_jobs)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index e637b10..b55c5b0 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -21,6 +21,7 @@ use utoipa::OpenApi; crate::libraries::create_library, crate::libraries::delete_library, crate::libraries::scan_library, + crate::libraries::update_monitoring, crate::tokens::list_tokens, crate::tokens::create_token, crate::tokens::revoke_token, @@ -43,6 +44,7 @@ use utoipa::OpenApi; crate::index_jobs::FolderItem, crate::libraries::LibraryResponse, crate::libraries::CreateLibraryRequest, + crate::libraries::UpdateMonitoringRequest, crate::tokens::CreateTokenRequest, crate::tokens::TokenResponse, crate::tokens::CreatedTokenResponse, diff --git a/apps/backoffice/app/components/MonitoringForm.tsx b/apps/backoffice/app/components/MonitoringForm.tsx new file mode 100644 index 0000000..655f6de --- /dev/null +++ b/apps/backoffice/app/components/MonitoringForm.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useTransition } from "react"; + +interface MonitoringFormProps { + libraryId: string; + monitorEnabled: boolean; + scanMode: string; +} + +export function MonitoringForm({ libraryId, monitorEnabled, scanMode }: MonitoringFormProps) { + const [isPending, startTransition] = useTransition(); + + const handleSubmit = (formData: FormData) => { + startTransition(async () => { + try { + const response = await fetch(`/api/libraries/${libraryId}/monitoring`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + monitor_enabled: formData.get("monitor_enabled") === "true", + scan_mode: formData.get("scan_mode"), + }), + }); + if (response.ok) { + window.location.reload(); + } + } catch (error) { + console.error("Failed to update monitoring:", error); + } + }); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/backoffice/app/globals.css b/apps/backoffice/app/globals.css index 7c35fce..300d352 100644 --- a/apps/backoffice/app/globals.css +++ b/apps/backoffice/app/globals.css @@ -1171,3 +1171,66 @@ tr.job-highlighted td { 0%, 100% { box-shadow: inset 0 0 0 1px hsl(198 78% 37% / 0.3); } 50% { box-shadow: inset 0 0 0 2px hsl(198 78% 37% / 0.6); } } + +/* Monitoring styles */ +.monitoring-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.monitor-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.monitor-auto { + background: hsl(142 60% 45% / 0.2); + color: hsl(142 60% 35%); + border: 1px solid hsl(142 60% 45% / 0.5); +} + +.monitor-manual { + background: hsl(220 13% 80% / 0.2); + color: hsl(220 13% 40%); + border: 1px solid hsl(220 13% 80% / 0.5); +} + +.scan-mode { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: capitalize; +} + +.next-scan { + font-size: 0.7rem; + color: hsl(198 78% 37%); +} + +.monitoring-form { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.monitor-toggle { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + cursor: pointer; +} + +.monitor-toggle input[type="checkbox"] { + cursor: pointer; +} + +.monitoring-form select { + font-size: 0.75rem; + padding: 2px 4px; +} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index cdff8ed..3570d01 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -4,6 +4,9 @@ export type LibraryDto = { root_path: string; enabled: boolean; book_count: number; + monitor_enabled: boolean; + scan_mode: string; + next_scan_at: string | null; }; export type IndexJobDto = { @@ -132,6 +135,13 @@ export async function scanLibrary(libraryId: string, full?: boolean) { }); } +export async function updateLibraryMonitoring(libraryId: string, monitorEnabled: boolean, scanMode: string) { + return apiFetch(`/libraries/${libraryId}/monitoring`, { + method: "PATCH", + body: JSON.stringify({ monitor_enabled: monitorEnabled, scan_mode: scanMode }) + }); +} + export async function listJobs() { return apiFetch("/index/status"); } diff --git a/apps/indexer/src/main.rs b/apps/indexer/src/main.rs index 997bebb..12fd00b 100644 --- a/apps/indexer/src/main.rs +++ b/apps/indexer/src/main.rs @@ -92,6 +92,18 @@ async fn ready(State(state): State) -> Result, async fn run_worker(state: AppState, interval_seconds: u64) { let wait = Duration::from_secs(interval_seconds.max(1)); + // Start scheduler task for auto-monitoring + let scheduler_state = state.clone(); + let _scheduler_handle = tokio::spawn(async move { + let scheduler_wait = Duration::from_secs(60); // Check every minute + loop { + if let Err(err) = check_and_schedule_auto_scans(&scheduler_state.pool).await { + error!("[SCHEDULER] Error: {}", err); + } + tokio::time::sleep(scheduler_wait).await; + } + }); + loop { match claim_next_job(&state.pool).await { Ok(Some((job_id, library_id))) => { @@ -115,6 +127,69 @@ async fn run_worker(state: AppState, interval_seconds: u64) { } } +async fn check_and_schedule_auto_scans(pool: &sqlx::PgPool) -> anyhow::Result<()> { + let libraries = sqlx::query( + r#" + SELECT id, scan_mode, last_scan_at + FROM libraries + WHERE monitor_enabled = TRUE + AND ( + next_scan_at IS NULL + OR next_scan_at <= NOW() + ) + AND NOT EXISTS ( + SELECT 1 FROM index_jobs + WHERE library_id = libraries.id + AND status IN ('pending', 'running') + ) + "# + ) + .fetch_all(pool) + .await?; + + for row in libraries { + let library_id: Uuid = row.get("id"); + let scan_mode: String = row.get("scan_mode"); + + info!("[SCHEDULER] Auto-scanning library {} (mode: {})", library_id, scan_mode); + + let job_id = Uuid::new_v4(); + let job_type = match scan_mode.as_str() { + "full" => "full_rebuild", + _ => "rebuild", + }; + + sqlx::query( + "INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, $3, 'pending')" + ) + .bind(job_id) + .bind(library_id) + .bind(job_type) + .execute(pool) + .await?; + + // Update next_scan_at + let interval_minutes = match scan_mode.as_str() { + "hourly" => 60, + "daily" => 1440, + "weekly" => 10080, + _ => 1440, // default daily + }; + + sqlx::query( + "UPDATE libraries SET last_scan_at = NOW(), next_scan_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1" + ) + .bind(library_id) + .bind(interval_minutes) + .execute(pool) + .await?; + + info!("[SCHEDULER] Created job {} for library {}", job_id, library_id); + } + + Ok(()) +} + async fn claim_next_job(pool: &sqlx::PgPool) -> anyhow::Result)>> { let mut tx = pool.begin().await?; let row = sqlx::query(