diff --git a/crates/pm-auth/src/lib.rs b/crates/pm-auth/src/lib.rs index 0990220..89e9f56 100644 --- a/crates/pm-auth/src/lib.rs +++ b/crates/pm-auth/src/lib.rs @@ -19,7 +19,7 @@ pub mod session; // Commonly re-exported types pub use jwt::{AccessClaims, JwtError}; -pub use password::{hash_password, verify_password, PasswordError}; pub use password::validate_password_strength; +pub use password::{hash_password, verify_password, PasswordError}; pub use rbac::{AuthConfig, AuthUser, UserRole}; pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser}; diff --git a/crates/pm-auth/src/password.rs b/crates/pm-auth/src/password.rs index 6366f43..d1538cd 100644 --- a/crates/pm-auth/src/password.rs +++ b/crates/pm-auth/src/password.rs @@ -90,7 +90,10 @@ pub fn validate_password_strength(password: &str) -> Result<(), String> { } let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"; if !password.chars().any(|c| special_chars.contains(c)) { - return Err("Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)".to_string()); + return Err( + "Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)" + .to_string(), + ); } Ok(()) } diff --git a/crates/pm-auth/src/session.rs b/crates/pm-auth/src/session.rs index 15918f7..1763eba 100644 --- a/crates/pm-auth/src/session.rs +++ b/crates/pm-auth/src/session.rs @@ -141,10 +141,12 @@ pub async fn login( return Err(SessionError::AccountLocked); } // Lockout period has expired — reset counters - sqlx::query("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1") - .bind(user.id) - .execute(pool) - .await?; + sqlx::query( + "UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1", + ) + .bind(user.id) + .execute(pool) + .await?; } // 2. Verify password @@ -156,12 +158,14 @@ pub async fn login( let new_attempts = user.failed_login_attempts + 1; if new_attempts >= 5 { let lock_until = Utc::now() + chrono::Duration::minutes(30); - sqlx::query("UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3") - .bind(new_attempts) - .bind(lock_until) - .bind(user.id) - .execute(pool) - .await?; + sqlx::query( + "UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3", + ) + .bind(new_attempts) + .bind(lock_until) + .bind(user.id) + .execute(pool) + .await?; tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts); } else { sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2") diff --git a/crates/pm-core/src/lib.rs b/crates/pm-core/src/lib.rs index 9530735..39362c4 100644 --- a/crates/pm-core/src/lib.rs +++ b/crates/pm-core/src/lib.rs @@ -12,12 +12,11 @@ pub use config::AppConfig; pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH}; pub use error::{AppError, ErrorResponse}; pub use models::{ - AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest, - ChangePasswordRequest, AdminResetPasswordRequest, CreateUserRequest, - DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck, - HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary, - RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest, - User, UserRole as DbUserRole, + AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest, + CreateHealthCheckRequest, CreateHostRequest, CreateUserRequest, DiscoveryCidrRequest, + DiscoveryResult, Group, HealthCheck, HealthCheckResult, HealthCheckWithResult, Host, + HostHealthStatus, HostSummary, RegisterDiscoveredRequest, UpdateGroupRequest, + UpdateHealthCheckRequest, UpdateUserRequest, User, UserRole as DbUserRole, }; // Re-export audit integrity types diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index 5c9590c..3a8889f 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -457,6 +457,7 @@ pub struct MaintenanceWindow { /// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily. pub recurrence_day: Option, pub enabled: bool, + pub auto_apply: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -474,6 +475,8 @@ pub struct CreateMaintenanceWindowRequest { pub recurrence_day: Option, /// Whether the window is active (default true). pub enabled: Option, + /// Whether to auto-create a patch_apply job when this window opens and patches are pending (default true). + pub auto_apply: Option, } /// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`. @@ -485,4 +488,5 @@ pub struct UpdateMaintenanceWindowRequest { pub duration_minutes: Option, pub recurrence_day: Option, pub enabled: Option, + pub auto_apply: Option, } diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 9ea1d64..c1e4451 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -14,7 +14,10 @@ use routes::azure_sso::SsoSession; use routes::ws::WsTicket; use serde_json::{json, Value}; use std::{net::SocketAddr, sync::Arc, time::Duration}; -use tower_http::{services::{ServeDir, ServeFile}, trace::TraceLayer}; +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; /// Shared application state threaded through Axum. #[derive(Clone)] diff --git a/crates/pm-web/src/routes/auth.rs b/crates/pm-web/src/routes/auth.rs index 8aba4fd..a59a1ab 100644 --- a/crates/pm-web/src/routes/auth.rs +++ b/crates/pm-web/src/routes/auth.rs @@ -13,15 +13,15 @@ use axum::{ extract::State, http::{HeaderMap, StatusCode}, response::Json, - routing::{get, post}, routing::delete, + routing::{get, post}, Router, }; use pm_auth::{ - mfa_totp, + hash_password, mfa_totp, rbac::AuthUser, session::{self, LoginRequest, LoginResponse}, - verify_password, hash_password, validate_password_strength, + validate_password_strength, verify_password, }; use serde::Deserialize; use serde_json::{json, Value}; @@ -39,7 +39,10 @@ pub fn public_router() -> Router { .route("/login", post(login_handler)) .route("/refresh", post(refresh_handler)) .route("/logout", post(logout_handler)) - .route("/force-change-password", post(force_change_password_handler)) + .route( + "/force-change-password", + post(force_change_password_handler), + ) } // ============================================================ @@ -265,9 +268,11 @@ async fn force_change_password_handler( None => { return Err(( StatusCode::UNAUTHORIZED, - Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })), + Json( + json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }), + ), )); - } + }, }; // Verify current password @@ -277,7 +282,9 @@ async fn force_change_password_handler( if !valid { return Err(( StatusCode::UNAUTHORIZED, - Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })), + Json( + json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }), + ), )); } @@ -385,20 +392,18 @@ async fn disable_mfa( Json(req): Json, ) -> Result, (StatusCode, Json)> { // Verify current password to confirm identity - let hash: Option = sqlx::query_scalar( - "SELECT password_hash FROM users WHERE id = $1", - ) - .bind(auth_user.user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to fetch password hash"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })? - .flatten(); + let hash: Option = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(auth_user.user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to fetch password hash"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })? + .flatten(); let hash_str = hash.unwrap_or_default(); let valid = verify_password(&req.password, &hash_str).unwrap_or(false); @@ -406,7 +411,9 @@ async fn disable_mfa( if !valid { return Err(( StatusCode::BAD_REQUEST, - Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })), + Json( + json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }), + ), )); } diff --git a/crates/pm-web/src/routes/maintenance_windows.rs b/crates/pm-web/src/routes/maintenance_windows.rs index 7127aed..b0ec544 100644 --- a/crates/pm-web/src/routes/maintenance_windows.rs +++ b/crates/pm-web/src/routes/maintenance_windows.rs @@ -74,7 +74,7 @@ async fn list_windows( let windows: Vec = sqlx::query_as( r#" SELECT id, host_id, label, recurrence, start_at, duration_minutes, - recurrence_day, enabled, created_at, updated_at + recurrence_day, enabled, auto_apply, created_at, updated_at FROM maintenance_windows WHERE host_id = $1 ORDER BY created_at ASC @@ -151,15 +151,16 @@ async fn create_window( let duration = req.duration_minutes.unwrap_or(60); let enabled = req.enabled.unwrap_or(true); + let auto_apply = req.auto_apply.unwrap_or(true); let window: MaintenanceWindow = sqlx::query_as( r#" INSERT INTO maintenance_windows - (host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled) + (host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled, auto_apply) VALUES - ($1, $2, $3, $4, $5, $6, $7) + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, host_id, label, recurrence, start_at, duration_minutes, - recurrence_day, enabled, created_at, updated_at + recurrence_day, enabled, auto_apply, created_at, updated_at "#, ) .bind(host_id) @@ -169,6 +170,7 @@ async fn create_window( .bind(duration) .bind(req.recurrence_day) .bind(enabled) + .bind(auto_apply) .fetch_one(&state.db) .await .map_err(|e| { @@ -220,7 +222,7 @@ async fn update_window( let existing: Option = sqlx::query_as( r#" SELECT id, host_id, label, recurrence, start_at, duration_minutes, - recurrence_day, enabled, created_at, updated_at + recurrence_day, enabled, auto_apply, created_at, updated_at FROM maintenance_windows WHERE id = $1 AND host_id = $2 "#, @@ -253,6 +255,7 @@ async fn update_window( let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes); let new_rec_day = req.recurrence_day.or(existing.recurrence_day); let new_enabled = req.enabled.unwrap_or(existing.enabled); + let new_auto_apply = req.auto_apply.unwrap_or(existing.auto_apply); // Validate recurrence_day for the final recurrence type. if new_recurrence == pm_core::models::WindowRecurrence::Weekly { @@ -289,10 +292,11 @@ async fn update_window( duration_minutes = $6, recurrence_day = $7, enabled = $8, + auto_apply = $9, updated_at = NOW() WHERE id = $1 AND host_id = $2 RETURNING id, host_id, label, recurrence, start_at, duration_minutes, - recurrence_day, enabled, created_at, updated_at + recurrence_day, enabled, auto_apply, created_at, updated_at "#, ) .bind(win_id) @@ -303,6 +307,7 @@ async fn update_window( .bind(new_duration) .bind(new_rec_day) .bind(new_enabled) + .bind(new_auto_apply) .fetch_one(&state.db) .await .map_err(|e| { diff --git a/crates/pm-web/src/routes/users.rs b/crates/pm-web/src/routes/users.rs index 1bc73df..45db929 100644 --- a/crates/pm-web/src/routes/users.rs +++ b/crates/pm-web/src/routes/users.rs @@ -15,11 +15,14 @@ use axum::{ routing::{delete, get, post, put}, Router, }; -use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password}; use pm_auth::validate_password_strength; +use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password}; use pm_core::{ audit::{log_event, AuditAction}, - models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User}, + models::{ + AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, + User, + }, }; use serde_json::{json, Value}; use uuid::Uuid; @@ -203,7 +206,9 @@ async fn update_user( )); } // Only admins can change role or active status - if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some()) && !auth.role.is_admin() { + if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some()) + && !auth.role.is_admin() + { return Err(( StatusCode::FORBIDDEN, Json( @@ -355,20 +360,18 @@ async fn change_own_password( Json(req): Json, ) -> Result, (StatusCode, Json)> { // Fetch current password hash - let hash: Option = sqlx::query_scalar( - "SELECT password_hash FROM users WHERE id = $1", - ) - .bind(auth.user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| { - tracing::error!(error = %e, "Failed to fetch password hash"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), - ) - })? - .flatten(); + let hash: Option = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(auth.user_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to fetch password hash"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })? + .flatten(); let hash_str = hash.unwrap_or_default(); let valid = verify_password(&req.current_password, &hash_str).unwrap_or(false); @@ -376,7 +379,9 @@ async fn change_own_password( if !valid { return Err(( StatusCode::BAD_REQUEST, - Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })), + Json( + json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }), + ), )); } diff --git a/crates/pm-worker/src/maintenance_scheduler.rs b/crates/pm-worker/src/maintenance_scheduler.rs index 880026a..50762de 100644 --- a/crates/pm-worker/src/maintenance_scheduler.rs +++ b/crates/pm-worker/src/maintenance_scheduler.rs @@ -1,8 +1,13 @@ //! Maintenance window scheduler. //! -//! Polls every 60 seconds and, for each enabled maintenance window that is -//! currently open, dispatches any queued non-immediate patch jobs associated -//! with the window's host. +//! Polls every 60 seconds and performs two tasks: +//! +//! 1. **Auto-apply**: For each enabled maintenance window with `auto_apply = true` +//! that is currently open, if the host has pending patches and no existing +//! patch_apply job queued/running for that window, automatically creates one. +//! +//! 2. **Dispatch**: For each open window, dispatch any queued non-immediate +//! patch jobs associated with the window's host. //! //! A window is considered "open" when: //! - `once` — `start_at <= NOW() < start_at + duration_minutes * '1 minute'` @@ -33,6 +38,23 @@ struct QueuedJobId { job_id: Uuid, } +#[derive(Debug, FromRow)] +struct AutoApplyWindow { + window_id: Uuid, + host_id: Uuid, +} + +#[derive(Debug, FromRow)] +struct PendingPatchHost { + host_id: Uuid, + patch_count: i32, +} + +#[derive(Debug, FromRow)] +struct InsertedJobId { + job_id: Uuid, +} + // ───────────────────────────────────────────────────────────────────────────── // Public entry point // ───────────────────────────────────────────────────────────────────────────── @@ -49,12 +71,206 @@ pub async fn run_maintenance_scheduler(pool: PgPool, config: Arc) { loop { ticker.tick().await; tracing::debug!("Maintenance scheduler: checking open windows"); + + // Step 1: Auto-create patch_apply jobs for windows with auto_apply=true + auto_create_patch_jobs(pool.clone(), config.clone()).await; + + // Step 2: Dispatch any queued non-immediate jobs for open windows dispatch_open_window_jobs(pool.clone(), config.clone()).await; } } // ───────────────────────────────────────────────────────────────────────────── -// Core dispatch logic +// Step 1: Auto-create patch_apply jobs +// ───────────────────────────────────────────────────────────────────────────── + +/// For each enabled maintenance window that is currently open AND has +/// `auto_apply = true`, check if the host has pending patches and no +/// existing patch_apply job for this window cycle. If so, create one. +async fn auto_create_patch_jobs(pool: PgPool, _config: Arc) { + // Find all open windows with auto_apply=true + let auto_windows: Vec = match sqlx::query_as( + r#" + SELECT mw.id AS window_id, mw.host_id + FROM maintenance_windows mw + WHERE mw.enabled = TRUE + AND mw.auto_apply = TRUE + AND ( + ( mw.recurrence = 'once' + AND mw.start_at <= NOW() + AND NOW() < mw.start_at + (mw.duration_minutes * INTERVAL '1 minute') + ) + OR + ( mw.recurrence = 'daily' + AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time + AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time + + (mw.duration_minutes * INTERVAL '1 minute')) + ) + OR + ( mw.recurrence = 'weekly' + AND EXTRACT(DOW FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day + AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time + AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time + + (mw.duration_minutes * INTERVAL '1 minute')) + ) + OR + ( mw.recurrence = 'monthly' + AND EXTRACT(DAY FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day + AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time + AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time + + (mw.duration_minutes * INTERVAL '1 minute')) + ) + ) + "#, + ) + .fetch_all(&pool) + .await + { + Ok(w) => w, + Err(e) => { + tracing::error!(error = %e, "auto_create_patch_jobs: open-windows query failed"); + return; + } + }; + + if auto_windows.is_empty() { + tracing::debug!("auto_create: no open auto-apply windows this cycle"); + return; + } + + tracing::info!( + auto_window_count = auto_windows.len(), + "auto_create: found open auto-apply windows" + ); + + for win in &auto_windows { + // Check if host has pending patches + let pending: Option = match sqlx::query_as( + r#" + SELECT host_id, patch_count + FROM host_patch_data + WHERE host_id = $1 AND patch_count > 0 + "#, + ) + .bind(win.host_id) + .fetch_optional(&pool) + .await + { + Ok(p) => p, + Err(e) => { + tracing::error!( + error = %e, + host_id = %win.host_id, + "auto_create: patch data query failed" + ); + continue; + }, + }; + + let Some(pending) = pending else { + tracing::debug!( + host_id = %win.host_id, + "auto_create: no pending patches, skipping" + ); + continue; + }; + + // Check if there's already a queued/running patch_apply job for this host + // that was created during this window cycle (within the window's time range). + // We use a simpler check: any non-completed patch_apply job for this host + // that references this maintenance window, OR any non-immediate job without + // a window that was created since the window opened. + let existing_job: bool = match sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 FROM patch_jobs pj + JOIN patch_job_hosts pjh ON pj.id = pjh.job_id + WHERE pjh.host_id = $1 + AND pj.status IN ('queued', 'running', 'pending') + AND pj.kind = 'patch_apply' + AND ( + pj.maintenance_window_id = $2 + OR + (pj.immediate = FALSE AND pj.created_at >= + (SELECT start_at - INTERVAL '5 minutes' FROM maintenance_windows WHERE id = $2) + ) + ) + ) + "#, + ) + .bind(win.host_id) + .bind(win.window_id) + .fetch_one(&pool) + .await + { + Ok(b) => b, + Err(e) => { + tracing::error!( + error = %e, + host_id = %win.host_id, + "auto_create: existing job check failed" + ); + continue; + } + }; + + if existing_job { + tracing::debug!( + host_id = %win.host_id, + window_id = %win.window_id, + "auto_create: existing job already queued/running, skipping" + ); + continue; + } + + // Create a new patch_apply job for this host, linked to the window. + let job: Option = match sqlx::query_as( + r#" + WITH new_job AS ( + INSERT INTO patch_jobs + (kind, status, maintenance_window_id, immediate, patch_selection, notes) + VALUES + ('patch_apply', 'queued', $1, FALSE, '[]'::jsonb, + 'Auto-created by maintenance window scheduler') + RETURNING id AS job_id + ) + INSERT INTO patch_job_hosts (job_id, host_id, status) + SELECT new_job.job_id, $2, 'queued' + FROM new_job + RETURNING job_id + "#, + ) + .bind(win.window_id) + .bind(win.host_id) + .fetch_optional(&pool) + .await + { + Ok(j) => j, + Err(e) => { + tracing::error!( + error = %e, + host_id = %win.host_id, + window_id = %win.window_id, + "auto_create: job insert failed" + ); + continue; + }, + }; + + if let Some(job) = job { + tracing::info!( + job_id = %job.job_id, + host_id = %win.host_id, + window_id = %win.window_id, + patch_count = pending.patch_count, + "auto_create: created patch_apply job for host in maintenance window" + ); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step 2: Dispatch queued non-immediate jobs // ───────────────────────────────────────────────────────────────────────────── /// Find all hosts with a currently-open maintenance window, then for each, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 084895e..0a7942d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,12 +61,12 @@ function AuthRestorer({ children }: { children: React.ReactNode }) { // Only call restoreSession AFTER Zustand has rehydrated the persisted state if (useAuthStore.persist.hasHydrated()) { - console.log('[auth] Store already hydrated, restoring session') + console.warn('[auth] Store already hydrated, restoring session') doRestore() } else { - console.log('[auth] Waiting for Zustand hydration...') + console.warn('[auth] Waiting for Zustand hydration...') unsub = useAuthStore.persist.onFinishHydration(() => { - console.log('[auth] Hydration complete, restoring session') + console.warn('[auth] Hydration complete, restoring session') doRestore() }) } diff --git a/frontend/src/pages/MaintenanceWindowsPage.tsx b/frontend/src/pages/MaintenanceWindowsPage.tsx index 2d6ec94..edc69e6 100644 --- a/frontend/src/pages/MaintenanceWindowsPage.tsx +++ b/frontend/src/pages/MaintenanceWindowsPage.tsx @@ -104,6 +104,7 @@ interface FormValues { duration_minutes: number recurrence_day: number | '' enabled: boolean + auto_apply: boolean } function defaultForm(): FormValues { @@ -114,6 +115,7 @@ function defaultForm(): FormValues { duration_minutes: 60, recurrence_day: '', enabled: true, + auto_apply: true, } } @@ -242,6 +244,18 @@ function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFor } label="Enabled" /> + set('auto_apply', e.target.checked)} + /> + } + label="Auto-Apply Patches" + /> + + When enabled, pending patches are automatically applied during this window. + @@ -346,6 +360,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow Schedule Recurrence Status + Auto-Apply Created Actions @@ -371,6 +386,13 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow size="small" /> + + + {fmtDate(w.created_at)} @@ -468,6 +490,7 @@ export default function MaintenanceWindowsPage() { duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, + auto_apply: values.auto_apply, }) setCreateOpen(false) showSnackbar('Maintenance window created', 'success') @@ -484,6 +507,7 @@ export default function MaintenanceWindowsPage() { duration_minutes: w.duration_minutes, recurrence_day: w.recurrence_day ?? '', enabled: w.enabled, + auto_apply: w.auto_apply, }) setEditOpen(true) } @@ -497,6 +521,7 @@ export default function MaintenanceWindowsPage() { duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, + auto_apply: values.auto_apply, }) setEditOpen(false) showSnackbar('Maintenance window updated', 'success') diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index ff03969..50cc9ad 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -131,6 +131,7 @@ export default function UsersPage() { } } + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { load() }, []) // Filtered users diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 3786222..7a463bb 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -35,7 +35,7 @@ export const useAuthStore = create()( restoreSession: async () => { const { refreshToken } = get() if (!refreshToken) { - console.log('[auth] No refresh token found, skipping restoration') + console.warn('[auth] No refresh token found, skipping restoration') set({ isRestoring: false }) return } @@ -46,7 +46,7 @@ export const useAuthStore = create()( { refresh_token: refreshToken }, { timeout: 10000 } ) - console.log('[auth] Token refresh successful') + console.warn('[auth] Token refresh successful') set({ accessToken: data.access_token, refreshToken: data.refresh_token, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1ddab44..a9e9979 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -171,6 +171,7 @@ export interface MaintenanceWindow { /** 0-6 for weekly (0=Sun), 1-31 for monthly, null for once/daily */ recurrence_day?: number | null enabled: boolean + auto_apply: boolean created_at: string updated_at: string } @@ -182,6 +183,7 @@ export interface CreateMaintenanceWindowRequest { duration_minutes?: number recurrence_day?: number | null enabled?: boolean + auto_apply?: boolean } export interface UpdateMaintenanceWindowRequest { @@ -191,6 +193,7 @@ export interface UpdateMaintenanceWindowRequest { duration_minutes?: number recurrence_day?: number | null enabled?: boolean + auto_apply?: boolean } // ── WebSocket event types (M7) ──────────────────────────────────────────────── diff --git a/migrations/012_account_lockout.sql b/migrations/012_account_lockout.sql index 1498758..78b2e55 100644 --- a/migrations/012_account_lockout.sql +++ b/migrations/012_account_lockout.sql @@ -1,3 +1,3 @@ -- Account lockout: track failed login attempts and lockout timestamps -ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0; -ALTER TABLE users ADD COLUMN locked_until TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; diff --git a/migrations/013_maintenance_window_auto_apply.sql b/migrations/013_maintenance_window_auto_apply.sql new file mode 100644 index 0000000..83623d1 --- /dev/null +++ b/migrations/013_maintenance_window_auto_apply.sql @@ -0,0 +1,8 @@ +-- Migration 013: Add auto_apply flag to maintenance windows +-- When true, the maintenance scheduler will automatically create a patch_apply job +-- for the host when the window opens and patches are pending. + +ALTER TABLE maintenance_windows + ADD COLUMN IF NOT EXISTS auto_apply boolean NOT NULL DEFAULT true; + +COMMENT ON COLUMN maintenance_windows.auto_apply IS 'When true, automatically create a patch_apply job when this window opens and the host has pending patches.';