feat(monitoring): T23 - Surveillance automatique des libraries

- 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()
This commit is contained in:
2026-03-06 11:42:41 +01:00
parent 5f51955f4d
commit 6e0a77fae0
7 changed files with 302 additions and 1 deletions

View File

@@ -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<chrono::DateTime<chrono::Utc>>,
}
#[derive(Deserialize, ToSchema)]
@@ -40,7 +43,7 @@ pub struct CreateLibraryRequest {
)]
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryResponse>>, 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<AppState>) -> Result<Json<Vec<Li
root_path: row.get("root_path"),
enabled: row.get("enabled"),
book_count: row.get("book_count"),
monitor_enabled: row.get("monitor_enabled"),
scan_mode: row.get("scan_mode"),
next_scan_at: row.get("next_scan_at"),
})
.collect();
@@ -102,6 +108,9 @@ pub async fn create_library(
root_path,
enabled: true,
book_count: 0,
monitor_enabled: false,
scan_mode: "manual".to_string(),
next_scan_at: None,
}))
}
@@ -210,3 +219,82 @@ pub async fn scan_library(
Ok(Json(crate::index_jobs::map_row(row)))
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateMonitoringRequest {
pub monitor_enabled: bool,
#[schema(value_type = String, example = "hourly")]
pub scan_mode: String, // 'manual', 'hourly', 'daily', 'weekly'
}
/// Update monitoring settings for a library
#[utoipa::path(
patch,
path = "/libraries/{id}/monitoring",
tag = "libraries",
params(
("id" = String, Path, description = "Library UUID"),
),
request_body = UpdateMonitoringRequest,
responses(
(status = 200, body = LibraryResponse),
(status = 404, description = "Library not found"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden - Admin scope required"),
),
security(("Bearer" = []))
)]
pub async fn update_monitoring(
State(state): State<AppState>,
AxumPath(library_id): AxumPath<Uuid>,
Json(input): Json<UpdateMonitoringRequest>,
) -> Result<Json<LibraryResponse>, 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"),
}))
}

View File

@@ -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))

View File

@@ -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,