diff --git a/Cargo.lock b/Cargo.lock index 170ad5d..4dde722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "anyhow", "chrono", "futures", + "pm-agent-client", "pm-core", "serde", "serde_json", diff --git a/crates/pm-agent-client/src/client.rs b/crates/pm-agent-client/src/client.rs index b087e6a..e023753 100644 --- a/crates/pm-agent-client/src/client.rs +++ b/crates/pm-agent-client/src/client.rs @@ -1,10 +1,284 @@ -//! Agent HTTP client stub. -//! Full mTLS Rustls-based implementation arrives in M4. +//! mTLS HTTP client for communicating with Linux Patch API agents. +//! +//! # Example +//! +//! ```no_run +//! use pm_agent_client::client::AgentClient; +//! +//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> { +//! let client = AgentClient::new( +//! "192.168.1.10", +//! 12443, +//! include_bytes!("../certs/client.crt"), +//! include_bytes!("../certs/client.key"), +//! include_bytes!("../certs/ca.crt"), +//! )?; +//! +//! let health = client.health().await?; +//! println!("Agent status: {}", health.status); +//! # Ok(()) +//! # } +//! ``` -use thiserror::Error; +use std::time::Duration; -#[derive(Debug, Error)] -pub enum AgentClientError { - #[error("Not yet implemented")] - NotImplemented, +use reqwest::{ + tls::Version, + Certificate, ClientBuilder, Identity, +}; +use serde::{de::DeserializeOwned, Serialize}; +use tracing::{debug, instrument}; + +use crate::{ + error::AgentClientError, + types::{ + AgentEnvelope, HealthData, PackagesData, PatchesData, SystemInfoData, + ApplyPatchesRequest, ApplyPatchesResponse, AgentJobStatus, RollbackResponse, + }, +}; + +/// Default TCP port that the Linux Patch API agent listens on. +pub const DEFAULT_AGENT_PORT: u16 = 12443; + +/// Request timeout applied to every agent API call. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +// ============================================================ +// AgentClient +// ============================================================ + +/// Async HTTP client that speaks mTLS to a single Linux Patch API agent. +/// +/// Construct once via [`AgentClient::new`] and reuse across calls; +/// the underlying [`reqwest::Client`] maintains a connection pool. +#[derive(Debug, Clone)] +pub struct AgentClient { + /// Underlying HTTP client (configured for mTLS + TLS 1.3). + inner: reqwest::Client, + /// Base URL of the agent, e.g. `https://10.0.0.5:12443/api/v1`. + base_url: String, +} + +impl AgentClient { + /// Create a new [`AgentClient`] configured for mTLS. + /// + /// # Arguments + /// + /// * `host_ip` – IP address (or hostname) of the agent. + /// * `port` – TCP port the agent listens on (default [`DEFAULT_AGENT_PORT`]). + /// * `client_cert_pem` – PEM-encoded client certificate presented during the TLS handshake. + /// * `client_key_pem` – PEM-encoded private key matching `client_cert_pem`. + /// * `ca_cert_pem` – PEM-encoded CA certificate used to verify the agent's server cert. + /// + /// # Errors + /// + /// Returns [`AgentClientError::Tls`] when certificate parsing fails, or + /// [`AgentClientError::Request`] when `reqwest` client construction fails. + pub fn new( + host_ip: &str, + port: u16, + client_cert_pem: &[u8], + client_key_pem: &[u8], + ca_cert_pem: &[u8], + ) -> Result { + // Build client identity: reqwest expects cert + key concatenated as PEM. + let mut identity_pem = Vec::with_capacity(client_cert_pem.len() + client_key_pem.len()); + identity_pem.extend_from_slice(client_cert_pem); + identity_pem.extend_from_slice(client_key_pem); + + let identity = Identity::from_pem(&identity_pem) + .map_err(|e| AgentClientError::Tls(format!("invalid client identity PEM: {e}")))?; + + // Parse the CA certificate used to verify the agent's server certificate. + let ca_cert = Certificate::from_pem(ca_cert_pem) + .map_err(|e| AgentClientError::Tls(format!("invalid CA certificate PEM: {e}")))?; + + // Build the reqwest client: + // - force rustls TLS backend + // - disable built-in OS/system trust roots (only trust our internal CA) + // - enforce TLS 1.3 minimum + // - attach client identity (mTLS) + // - add our CA as a trusted root + // - apply a global request timeout + let inner = ClientBuilder::new() + .use_rustls_tls() + .tls_built_in_root_certs(false) + .min_tls_version(Version::TLS_1_3) + .identity(identity) + .add_root_certificate(ca_cert) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| AgentClientError::Request(e))?; + + let base_url = format!("https://{}:{}/api/v1", host_ip, port); + tracing::debug!(base_url = %base_url, "AgentClient created"); + + Ok(Self { inner, base_url }) + } + + // -------------------------------------------------------- + // Public API methods + // -------------------------------------------------------- + + /// `GET /api/v1/health` — check agent liveness and retrieve uptime. + #[instrument(skip(self), fields(base_url = %self.base_url))] + pub async fn health(&self) -> Result { + self.get("health", &[]).await + } + + /// `GET /api/v1/system/info` — retrieve host system information. + #[instrument(skip(self), fields(base_url = %self.base_url))] + pub async fn system_info(&self) -> Result { + self.get("system/info", &[]).await + } + + /// `GET /api/v1/packages?status=upgradable` — list packages with available upgrades. + #[instrument(skip(self), fields(base_url = %self.base_url))] + pub async fn packages_upgradable(&self) -> Result { + self.get("packages", &[("status", "upgradable")]).await + } + + /// `GET /api/v1/patches` — list available patches with severity and CVE data. + #[instrument(skip(self), fields(base_url = %self.base_url))] + pub async fn patches(&self) -> Result { + self.get("patches", &[]).await + } + + // -------------------------------------------------------- + // Private helpers + // -------------------------------------------------------- + + /// Execute a GET request against `{base_url}/{path}` with optional query + /// parameters, deserialize the [`AgentEnvelope`], and extract the `data` + /// field — or propagate an [`AgentClientError::ApiError`]. + async fn get( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result + where + T: DeserializeOwned, + { + let url = format!("{}/{}", self.base_url, path); + debug!(url = %url, ?query, "Sending GET request to agent"); + + let mut request = self.inner.get(&url); + if !query.is_empty() { + request = request.query(query); + } + + let response = request.send().await?; + let status = response.status(); + debug!(url = %url, status = %status, "Received response from agent"); + + // Capture body text so we can attempt to deserialise the error envelope + // even for non-2xx responses. + let body = response.text().await?; + + // Attempt to parse the standard agent envelope regardless of HTTP status. + // The agent may embed a structured error body on 4xx/5xx responses. + let envelope: AgentEnvelope = serde_json::from_str(&body)?; + + if !status.is_success() || !envelope.success { + // Prefer the structured error from the envelope when present. + if let Some(err) = envelope.error { + return Err(AgentClientError::ApiError { + code: err.code, + message: err.message, + }); + } + // Fallback: use the HTTP status as the error indicator. + return Err(AgentClientError::ApiError { + code: status.as_str().to_string(), + message: format!( + "Agent returned HTTP {} for {}", + status.as_u16(), + url + ), + }); + } + + // On success the `data` field must be present. + envelope.data.ok_or_else(|| AgentClientError::ApiError { + code: "MISSING_DATA".to_string(), + message: "Agent response success=true but data field is absent".to_string(), + }) + } + + // -------------------------------------------------------- + // Patch apply / job management methods + // -------------------------------------------------------- + + /// `POST /api/v1/patches/apply` — trigger patch application on the agent. + #[instrument(skip(self, req), fields(base_url = %self.base_url))] + pub async fn apply_patches( + &self, + req: &ApplyPatchesRequest, + ) -> Result { + self.post("patches/apply", req).await + } + + /// `GET /api/v1/jobs/{id}` — poll an async agent job for status. + #[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))] + pub async fn job_status( + &self, + job_id: &str, + ) -> Result { + self.get(&format!("jobs/{}", job_id), &[]).await + } + + /// `POST /api/v1/jobs/{id}/rollback` — trigger rollback on the agent. + #[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))] + pub async fn rollback_job( + &self, + job_id: &str, + ) -> Result { + let empty: serde_json::Value = serde_json::json!({}); + self.post(&format!("jobs/{}/rollback", job_id), &empty).await + } + + // -------------------------------------------------------- + // Private POST helper + // -------------------------------------------------------- + + /// Execute a POST request against `{base_url}/{path}`, serialize `body` as + /// JSON, deserialize the [`AgentEnvelope`], and extract the `data` field — + /// or propagate an [`AgentClientError::ApiError`]. + async fn post( + &self, + path: &str, + body: &Req, + ) -> Result + where + Req: Serialize, + Resp: DeserializeOwned, + { + let url = format!("{}/{}", self.base_url, path); + debug!(url = %url, "Sending POST request to agent"); + + let response = self.inner.post(&url).json(body).send().await?; + let status = response.status(); + debug!(url = %url, status = %status, "Received POST response from agent"); + + let body_text = response.text().await?; + let envelope: AgentEnvelope = serde_json::from_str(&body_text)?; + + if !status.is_success() || !envelope.success { + if let Some(err) = envelope.error { + return Err(AgentClientError::ApiError { + code: err.code, + message: err.message, + }); + } + return Err(AgentClientError::ApiError { + code: status.as_str().to_string(), + message: format!("Agent returned HTTP {} for {}", status.as_u16(), url), + }); + } + + envelope.data.ok_or_else(|| AgentClientError::ApiError { + code: "MISSING_DATA".to_string(), + message: "Agent response success=true but data field is absent".to_string(), + }) + } } diff --git a/crates/pm-agent-client/src/error.rs b/crates/pm-agent-client/src/error.rs new file mode 100644 index 0000000..021d067 --- /dev/null +++ b/crates/pm-agent-client/src/error.rs @@ -0,0 +1,49 @@ +//! Error types for the pm-agent-client crate. + +use thiserror::Error; + +/// Top-level error type returned by [`crate::client::AgentClient`] methods. +#[derive(Debug, Error)] +pub enum AgentClientError { + /// TLS configuration or handshake failure. + #[error("TLS error: {0}")] + Tls(String), + + /// Unable to establish a TCP/TLS connection to the agent. + #[error("Connection error: {0}")] + Connect(#[source] reqwest::Error), + + /// An HTTP request or response transport error (not a timeout). + #[error("Request error: {0}")] + Request(#[source] reqwest::Error), + + /// The request did not complete within the configured timeout. + #[error("Request timed out")] + Timeout, + + /// The agent returned a non-2xx HTTP status or `success: false` in the + /// response envelope. + #[error("Agent API error [{code}]: {message}")] + ApiError { + /// Machine-readable error code supplied by the agent (e.g. `"NOT_FOUND"`). + code: String, + /// Human-readable description returned by the agent. + message: String, + }, + + /// JSON deserialization of the agent response failed. + #[error("Failed to deserialise agent response: {0}")] + Deserialize(#[from] serde_json::Error), +} + +impl From for AgentClientError { + fn from(err: reqwest::Error) -> Self { + if err.is_timeout() { + AgentClientError::Timeout + } else if err.is_connect() { + AgentClientError::Connect(err) + } else { + AgentClientError::Request(err) + } + } +} diff --git a/crates/pm-agent-client/src/lib.rs b/crates/pm-agent-client/src/lib.rs index fe6a869..572ad39 100644 --- a/crates/pm-agent-client/src/lib.rs +++ b/crates/pm-agent-client/src/lib.rs @@ -1,4 +1,46 @@ -//! pm-agent-client — mTLS HTTP client for Linux Patch API agent communication. +//! `pm-agent-client` — mTLS HTTP client for Linux Patch API agent communication. //! -//! M1: Stub. Full implementation in M4. +//! This crate provides [`client::AgentClient`], an async HTTP client that +//! establishes mutual-TLS connections (TLS 1.3) to `linux_patch_api` agents +//! running on managed hosts. +//! +//! # Quick start +//! +//! ```no_run +//! use pm_agent_client::AgentClient; +//! +//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> { +//! let client = AgentClient::new( +//! "10.0.1.5", +//! 12443, +//! include_bytes!("../certs/client.crt"), +//! include_bytes!("../certs/client.key"), +//! include_bytes!("../certs/ca.crt"), +//! )?; +//! +//! let health = client.health().await?; +//! println!("Agent {}: {}", health.status, health.version); +//! # Ok(()) +//! # } +//! ``` + pub mod client; +pub mod error; +pub mod types; + +// ── Convenience re-exports ────────────────────────────────────────────────── + +/// Primary client — re-exported from [`client::AgentClient`]. +pub use client::{AgentClient, DEFAULT_AGENT_PORT}; + +/// Error type — re-exported from [`error::AgentClientError`]. +pub use error::AgentClientError; + +/// Response envelope and all data types. +pub use types::{ + AgentEnvelope, AgentErrorBody, + HealthData, + Package, PackagesData, + Patch, PatchesData, + SystemInfoData, +}; diff --git a/crates/pm-agent-client/src/types.rs b/crates/pm-agent-client/src/types.rs new file mode 100644 index 0000000..bce071e --- /dev/null +++ b/crates/pm-agent-client/src/types.rs @@ -0,0 +1,205 @@ +//! Response and request types for the Linux Patch API agent endpoints. +//! +//! All agent responses are wrapped in [`AgentEnvelope`]. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ============================================================ +// Envelope & error +// ============================================================ + +/// Generic response wrapper returned by every agent endpoint. +/// +/// ```json +/// { "success": true, "request_id": "…", "timestamp": "…", "data": {…}, "error": null } +/// ``` +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AgentEnvelope { + /// `true` when the request succeeded; `false` on error. + pub success: bool, + /// Server-assigned request identifier (UUID v4). + pub request_id: Uuid, + /// Server timestamp for the response (ISO-8601 / RFC-3339). + pub timestamp: DateTime, + /// Response payload — present when `success` is `true`. + pub data: Option, + /// Error detail — present when `success` is `false`. + pub error: Option, +} + +/// Structured error returned inside [`AgentEnvelope::error`]. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AgentErrorBody { + /// Machine-readable error code (e.g. `"INTERNAL_ERROR"`). + pub code: String, + /// Human-readable description of what went wrong. + pub message: String, + /// Optional free-form extra detail from the agent. + #[serde(default)] + pub details: Option, + /// Whether the caller may safely retry the request. + #[serde(default)] + pub retryable: bool, +} + +// ============================================================ +// GET /api/v1/health +// ============================================================ + +/// Payload returned by `GET /api/v1/health`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HealthData { + /// Agent status string, e.g. `"ok"` or `"degraded"`. + pub status: String, + /// Seconds elapsed since the agent process started. + pub uptime_seconds: u64, + /// Agent software version string. + pub version: String, +} + +// ============================================================ +// GET /api/v1/system/info +// ============================================================ + +/// Payload returned by `GET /api/v1/system/info`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SystemInfoData { + /// Hostname of the managed system. + pub hostname: String, + /// OS family / distribution name (e.g. `"Ubuntu"`). + pub os: String, + /// OS version string. + pub os_version: String, + /// Kernel version string. + pub kernel: String, + /// CPU architecture (e.g. `"x86_64"`). + pub architecture: String, + /// When the agent last checked for updates (`null` if never). + pub last_update_check: Option>, + /// When updates were last applied (`null` if never). + pub last_update_apply: Option>, + /// Whether the system has a pending reboot. + pub pending_reboot: bool, +} + +// ============================================================ +// GET /api/v1/packages?status=upgradable +// ============================================================ + +/// A single package entry. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Package { + /// Package name. + pub name: String, + /// Installed version. + pub version: String, + /// Package status string (e.g. `"installed"`, `"upgradable"`). + pub status: String, + /// Whether a newer version is available. + pub upgradable: bool, + /// Latest available version (`null` if not upgradable). + pub latest_version: Option, + /// Short package description. + pub description: String, + /// CVE identifiers associated with this package. + #[serde(default)] + pub cve_ids: Vec, +} + +/// Payload returned by `GET /api/v1/packages`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PackagesData { + /// List of packages matching the query filters. + pub packages: Vec, + /// Total count of matching packages. + pub total: u64, +} + +// ============================================================ +// GET /api/v1/patches +// ============================================================ + +/// A single available patch. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Patch { + /// Package / patch name. + pub name: String, + /// Currently installed version. + pub current_version: String, + /// Version available after applying this patch. + pub available_version: String, + /// Severity level (e.g. `"critical"`, `"high"`, `"medium"`, `"low"`). + pub severity: String, + /// Human-readable description of the patch. + pub description: String, + /// CVE identifiers addressed by this patch. + #[serde(default)] + pub cve_ids: Vec, + /// Whether applying this patch requires a system reboot. + pub requires_reboot: bool, +} + +/// Payload returned by `GET /api/v1/patches`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PatchesData { + /// List of available patches. + pub patches: Vec, + /// Total patch count. + pub total: u64, + /// Number of patches classified as security updates. + pub security_updates: u64, + /// Whether any patch in the list requires a reboot. + pub requires_reboot: bool, +} + +// ============================================================ +// POST /api/v1/patches/apply +// ============================================================ + +/// Request body for `POST /api/v1/patches/apply`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplyPatchesRequest { + /// Package names to apply. Empty = apply all available patches. + pub packages: Vec, + /// If true, allow automatic reboot after patching if required. + pub allow_reboot: bool, +} + +/// Response from `POST /api/v1/patches/apply`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApplyPatchesResponse { + /// Agent-assigned async job ID for status polling. + pub job_id: String, + /// Initial status: typically `"running"` or `"queued"`. + pub status: String, +} + +// ============================================================ +// GET /api/v1/jobs/{id} +// ============================================================ + +/// Status of an async agent job returned by `GET /api/v1/jobs/{id}`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AgentJobStatus { + pub job_id: String, + /// Current status: `"running"`, `"succeeded"`, `"failed"`, or `"cancelled"`. + pub status: String, + pub progress_percent: Option, + pub output: Option, + pub error: Option, + pub started_at: Option>, + pub completed_at: Option>, +} + +// ============================================================ +// POST /api/v1/jobs/{id}/rollback +// ============================================================ + +/// Response from `POST /api/v1/jobs/{id}/rollback`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RollbackResponse { + pub job_id: String, + pub status: String, +} diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index fe4e31d..b76fe96 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -192,3 +192,109 @@ pub struct RegisterDiscoveredRequest { pub display_name: Option, pub group_ids: Option>, } + +// ============================================================ +// Patch Jobs +// ============================================================ + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "job_status", rename_all = "lowercase")] +pub enum JobStatus { + Queued, + Pending, + Running, + Succeeded, + Failed, + Cancelled, +} + +impl std::fmt::Display for JobStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Queued => write!(f, "queued"), + Self::Pending => write!(f, "pending"), + Self::Running => write!(f, "running"), + Self::Succeeded => write!(f, "succeeded"), + Self::Failed => write!(f, "failed"), + Self::Cancelled => write!(f, "cancelled"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "job_kind", rename_all = "snake_case")] +pub enum JobKind { + #[sqlx(rename = "patch_apply")] + PatchApply, + #[sqlx(rename = "patch_remove")] + PatchRemove, + Reboot, + Rollback, +} + +/// Full `patch_jobs` row. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct PatchJob { + pub id: Uuid, + pub kind: JobKind, + pub status: JobStatus, + pub created_by_user_id: Option, + pub parent_job_id: Option, + pub maintenance_window_id: Option, + pub immediate: bool, + pub patch_selection: serde_json::Value, + pub notes: String, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// Full `patch_job_hosts` row (includes columns added in migration 003). +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct PatchJobHost { + pub id: Uuid, + pub job_id: Uuid, + pub host_id: Uuid, + pub status: JobStatus, + pub agent_job_id: Option, + pub retry_count: i32, + pub output: String, + pub error_message: Option, + pub retry_next_at: Option>, + pub last_error: Option, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// Request payload for creating a patch job via `POST /api/v1/jobs`. +#[derive(Debug, Deserialize)] +pub struct CreateJobRequest { + /// Host IDs to patch. + pub host_ids: Vec, + /// Package names to apply (empty = all available patches). + pub packages: Vec, + /// If true: apply immediately. If false: queue for next maintenance window. + pub immediate: bool, + /// Optional maintenance window to bind to. + pub maintenance_window_id: Option, + /// Allow reboot if required by patches. + pub allow_reboot: Option, + /// Optional operator notes. + pub notes: Option, +} + +/// Summary row for job list view (aggregates per-host counts). +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct PatchJobSummary { + pub id: Uuid, + pub kind: JobKind, + pub status: JobStatus, + pub immediate: bool, + pub host_count: i64, + pub succeeded_count: i64, + pub failed_count: i64, + pub notes: String, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 8294fb1..f85d2dc 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -105,6 +105,10 @@ pub fn build_router(state: AppState) -> Router { .nest("/users", routes::users::router()) // Discovery .nest("/discovery", routes::discovery::router()) + // Fleet status + .nest("/status", routes::status::router()) + // Patch jobs + .nest("/jobs", routes::jobs::router()) // Apply auth middleware to all the above .route_layer(middleware::from_fn(move |req, next| { let auth_config = auth_config.clone(); diff --git a/crates/pm-web/src/routes/hosts.rs b/crates/pm-web/src/routes/hosts.rs index 039c926..c1b881a 100644 --- a/crates/pm-web/src/routes/hosts.rs +++ b/crates/pm-web/src/routes/hosts.rs @@ -7,6 +7,7 @@ //! GET /api/v1/hosts/{id}/groups — list groups for host //! POST /api/v1/hosts/{id}/groups — assign host to group //! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group +//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (operator+) use axum::{ extract::{Path, Query, State}, @@ -34,6 +35,7 @@ pub fn router() -> Router { .route("/:id", get(get_host).delete(remove_host)) .route("/:id/groups", get(list_host_groups).post(add_host_to_group)) .route("/:id/groups/:group_id", delete(remove_host_from_group)) + .route("/:id/refresh", post(refresh_host)) } // ── Query params ───────────────────────────────────────────────────────────── @@ -470,3 +472,56 @@ async fn resolve_fqdn(fqdn: &str) -> Result { _ => Err(format!("Failed to resolve FQDN: {fqdn}")), } } + +// ── POST /api/v1/hosts/:id/refresh ─────────────────────────────────────────── + +/// Queue an on-demand health + patch refresh for a single host. +/// +/// Sends a PostgreSQL NOTIFY on the `refresh_requested` channel; the +/// pm-worker refresh listener picks this up and polls the host immediately. +/// Requires Operator or Admin role (any authenticated user). +async fn refresh_host( + State(state): State, + _auth: AuthUser, + Path(id): Path, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + // Verify the host exists. + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)") + .bind(id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "refresh_host: db error checking host existence"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + + if !exists { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })), + )); + } + + // NOTIFY the worker's refresh listener. + sqlx::query("SELECT pg_notify('refresh_requested', $1)") + .bind(id.to_string()) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "refresh_host: pg_notify failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Failed to queue refresh" } })), + ) + })?; + + tracing::info!(%id, "On-demand refresh queued"); + + Ok(( + StatusCode::ACCEPTED, + Json(json!({ "message": "Refresh queued" })), + )) +} diff --git a/crates/pm-web/src/routes/jobs.rs b/crates/pm-web/src/routes/jobs.rs new file mode 100644 index 0000000..42adfc3 --- /dev/null +++ b/crates/pm-web/src/routes/jobs.rs @@ -0,0 +1,609 @@ +//! Patch job management routes. +//! +//! POST /api/v1/jobs — create a new patch job (operator+) +//! GET /api/v1/jobs — list jobs with pagination (RBAC scoped) +//! GET /api/v1/jobs/{id} — get job detail + per-host status +//! POST /api/v1/jobs/{id}/cancel — cancel a queued/pending job (admin or creator) +//! POST /api/v1/jobs/{id}/rollback — create a rollback job (admin only) + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use pm_auth::rbac::AuthUser; +use pm_core::{ + audit::{log_event, AuditAction}, + models::{CreateJobRequest, PatchJobSummary}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::AppState; + +// ── Router ──────────────────────────────────────────────────────────────────── + +pub fn router() -> Router { + Router::new() + .route("/", get(list_jobs).post(create_job)) + .route("/:id", get(get_job)) + .route("/:id/cancel", post(cancel_job)) + .route("/:id/rollback", post(rollback_job)) +} + +// ── Query params ────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct JobListQuery { + pub limit: Option, + pub offset: Option, +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct JobListResponse { + jobs: Vec, + total: i64, + limit: i64, + offset: i64, +} + +/// Per-host row included in `GET /api/v1/jobs/{id}` response. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +struct JobHostRow { + pub id: Uuid, + pub host_id: Uuid, + pub display_name: String, + pub status: String, + pub agent_job_id: Option, + pub retry_count: i32, + pub output: String, + pub error_message: Option, + pub last_error: Option, + pub started_at: Option>, + pub completed_at: Option>, +} + +// ── Error helper ────────────────────────────────────────────────────────────── + +#[inline] +fn err( + status: StatusCode, + code: &'static str, + message: impl Into, +) -> (StatusCode, Json) { + ( + status, + Json(json!({ "error": { "code": code, "message": message.into() } })), + ) +} + +// ── RBAC helper ─────────────────────────────────────────────────────────────── + +/// Returns `true` when the operator's groups contain at least one host that +/// belongs to the given job. Admins always pass this check at the call site. +async fn operator_can_access_job( + pool: &sqlx::PgPool, + user_id: Uuid, + job_id: Uuid, +) -> Result { + sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 + FROM patch_job_hosts pjh + JOIN host_groups hg ON hg.host_id = pjh.host_id + JOIN user_groups ug ON ug.group_id = hg.group_id + WHERE pjh.job_id = $1 + AND ug.user_id = $2 + ) + "#, + ) + .bind(job_id) + .bind(user_id) + .fetch_one(pool) + .await +} + +// ── POST /api/v1/jobs ───────────────────────────────────────────────────────── + +async fn create_job( + State(state): State, + auth: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if req.host_ids.is_empty() { + return Err(err( + StatusCode::BAD_REQUEST, + "bad_request", + "host_ids must not be empty", + )); + } + + // Encode package list as JSONB. + let patch_selection = serde_json::to_value(&req.packages).unwrap_or(json!([])); + let notes = req.notes.clone().unwrap_or_default(); + + // Insert the parent job row; the DB NOTIFY trigger fires automatically + // when immediate = TRUE (see migration 003_jobs_scheduling.sql). + let job_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO patch_jobs + (kind, status, created_by_user_id, maintenance_window_id, + immediate, patch_selection, notes) + VALUES + ('patch_apply'::job_kind, 'queued'::job_status, $1, $2, $3, $4, $5) + RETURNING id + "#, + ) + .bind(auth.user_id) + .bind(req.maintenance_window_id) + .bind(req.immediate) + .bind(&patch_selection) + .bind(¬es) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "create_job: insert patch_jobs failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + // Insert one patch_job_hosts row per requested host. + for host_id in &req.host_ids { + sqlx::query( + r#" + INSERT INTO patch_job_hosts (job_id, host_id, status) + VALUES ($1, $2, 'queued'::job_status) + ON CONFLICT (job_id, host_id) DO NOTHING + "#, + ) + .bind(job_id) + .bind(host_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!( + error = %e, %job_id, %host_id, + "create_job: insert patch_job_hosts failed" + ); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + } + + log_event( + &state.db, + AuditAction::PatchJobCreated, + Some(auth.user_id), + Some(&auth.username), + Some("job"), + Some(&job_id.to_string()), + json!({ + "kind": "patch_apply", + "immediate": req.immediate, + "host_count": req.host_ids.len(), + "packages": req.packages, + "notes": notes, + }), + None, + None, + ) + .await; + + tracing::info!( + job_id = %job_id, + host_count = req.host_ids.len(), + immediate = req.immediate, + user = %auth.username, + "Patch job created" + ); + + Ok(Json(json!({ "id": job_id, "message": "Job created" }))) +} + +// ── GET /api/v1/jobs ────────────────────────────────────────────────────────── + +async fn list_jobs( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> Result, (StatusCode, Json)> { + let limit = q.limit.unwrap_or(50).min(200); + let offset = q.offset.unwrap_or(0); + + let jobs: Vec = if auth.role.is_admin() { + // Admins see every job. + sqlx::query_as( + r#" + SELECT + pj.id, + pj.kind, + pj.status, + pj.immediate, + pj.notes, + pj.created_at, + pj.started_at, + pj.completed_at, + COUNT(pjh.id) AS host_count, + COUNT(pjh.id) FILTER (WHERE pjh.status = 'succeeded'::job_status) AS succeeded_count, + COUNT(pjh.id) FILTER (WHERE pjh.status = 'failed'::job_status) AS failed_count + FROM patch_jobs pj + LEFT JOIN patch_job_hosts pjh ON pjh.job_id = pj.id + GROUP BY pj.id + ORDER BY pj.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await + } else { + // Operators: only jobs where at least one host is in their groups. + sqlx::query_as( + r#" + SELECT + pj.id, + pj.kind, + pj.status, + pj.immediate, + pj.notes, + pj.created_at, + pj.started_at, + pj.completed_at, + COUNT(pjh.id) AS host_count, + COUNT(pjh.id) FILTER (WHERE pjh.status = 'succeeded'::job_status) AS succeeded_count, + COUNT(pjh.id) FILTER (WHERE pjh.status = 'failed'::job_status) AS failed_count + FROM patch_jobs pj + LEFT JOIN patch_job_hosts pjh ON pjh.job_id = pj.id + WHERE EXISTS ( + SELECT 1 + FROM patch_job_hosts pjh2 + JOIN host_groups hg ON hg.host_id = pjh2.host_id + JOIN user_groups ug ON ug.group_id = hg.group_id + WHERE pjh2.job_id = pj.id + AND ug.user_id = $3 + ) + GROUP BY pj.id + ORDER BY pj.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .bind(auth.user_id) + .fetch_all(&state.db) + .await + } + .map_err(|e| { + tracing::error!(error = %e, "list_jobs: query failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + // Total count for pagination metadata. + let total: i64 = if auth.role.is_admin() { + sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs") + .fetch_one(&state.db) + .await + .unwrap_or(0) + } else { + sqlx::query_scalar( + r#" + SELECT COUNT(DISTINCT pj.id) + FROM patch_jobs pj + WHERE EXISTS ( + SELECT 1 + FROM patch_job_hosts pjh + JOIN host_groups hg ON hg.host_id = pjh.host_id + JOIN user_groups ug ON ug.group_id = hg.group_id + WHERE pjh.job_id = pj.id + AND ug.user_id = $1 + ) + "#, + ) + .bind(auth.user_id) + .fetch_one(&state.db) + .await + .unwrap_or(0) + }; + + Ok(Json(JobListResponse { jobs, total, limit, offset })) +} +// ── GET /api/v1/jobs/:id ───────────────────────────────────────────────────── + +async fn get_job( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + // RBAC: operators may only view jobs touching their group's hosts. + if !auth.role.is_admin() { + let allowed = operator_can_access_job(&state.db, auth.user_id, id) + .await + .unwrap_or(false); + if !allowed { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Access denied", + )); + } + } + + // Fetch the job header row as JSON. + let job: Option = sqlx::query_scalar( + r#" + SELECT row_to_json(j) FROM ( + SELECT id, kind, status, created_by_user_id, parent_job_id, + maintenance_window_id, immediate, patch_selection, notes, + created_at, started_at, completed_at + FROM patch_jobs + WHERE id = $1 + ) j + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "get_job: failed to fetch job"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + let job = job.ok_or_else(|| { + err(StatusCode::NOT_FOUND, "not_found", "Job not found") + })?; + + // Fetch per-host status rows joined to the host display name. + let hosts: Vec = sqlx::query_as( + r#" + SELECT + pjh.id, + pjh.host_id, + COALESCE(h.display_name, h.fqdn) AS display_name, + pjh.status::text AS status, + pjh.agent_job_id, + pjh.retry_count, + pjh.output, + pjh.error_message, + pjh.last_error, + pjh.started_at, + pjh.completed_at + FROM patch_job_hosts pjh + JOIN hosts h ON h.id = pjh.host_id + WHERE pjh.job_id = $1 + ORDER BY h.display_name + "#, + ) + .bind(id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "get_job: failed to fetch host rows"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + Ok(Json(json!({ "job": job, "hosts": hosts }))) +} + +// ── POST /api/v1/jobs/:id/cancel ───────────────────────────────────────────── + +async fn cancel_job( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + // Fetch the job to verify it exists and check ownership. + let row: Option<(String, Option)> = sqlx::query_as( + "SELECT status::text, created_by_user_id FROM patch_jobs WHERE id = $1", + ) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "cancel_job: db fetch failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + let (status_str, creator_id) = row.ok_or_else(|| { + err(StatusCode::NOT_FOUND, "not_found", "Job not found") + })?; + + // Only admin or the job creator may cancel. + if !auth.role.is_admin() { + let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id); + if !is_creator { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Only admin or the job creator may cancel this job", + )); + } + } + + // Only queued or pending jobs can be cancelled. + if status_str != "queued" && status_str != "pending" { + return Err(err( + StatusCode::CONFLICT, + "invalid_state", + format!( + "Cannot cancel a job in '{}' state; only queued or pending jobs may be cancelled", + status_str + ), + )); + } + + // Cancel the parent job. + sqlx::query( + "UPDATE patch_jobs SET status = 'cancelled'::job_status WHERE id = $1", + ) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "cancel_job: update patch_jobs failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + // Cancel all queued/pending host rows for this job. + sqlx::query( + r#" + UPDATE patch_job_hosts + SET status = 'cancelled'::job_status + WHERE job_id = $1 + AND status IN ('queued'::job_status, 'pending'::job_status) + "#, + ) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "cancel_job: update patch_job_hosts failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + log_event( + &state.db, + AuditAction::PatchJobCancelled, + Some(auth.user_id), + Some(&auth.username), + Some("job"), + Some(&id.to_string()), + json!({ "previous_status": status_str }), + None, + None, + ) + .await; + + tracing::info!(job_id = %id, user = %auth.username, "Patch job cancelled"); + Ok(Json(json!({ "message": "Job cancelled" }))) +} + +// ── POST /api/v1/jobs/:id/rollback ──────────────────────────────────────────── + +async fn rollback_job( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + // Admin-only operation. + if !auth.role.is_admin() { + return Err(err( + StatusCode::FORBIDDEN, + "forbidden", + "Admin role required to create rollback jobs", + )); + } + + // Verify the original job exists. + let original_exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM patch_jobs WHERE id = $1)") + .bind(id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "rollback_job: existence check failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + if !original_exists { + return Err(err(StatusCode::NOT_FOUND, "not_found", "Job not found")); + } + + // Gather the host IDs from the original job. + let host_ids: Vec = + sqlx::query_scalar("SELECT host_id FROM patch_job_hosts WHERE job_id = $1") + .bind(id) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %id, "rollback_job: host fetch failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + if host_ids.is_empty() { + return Err(err( + StatusCode::UNPROCESSABLE_ENTITY, + "no_hosts", + "Original job has no host entries to roll back", + )); + } + + // Create the rollback job row (immediate = true so the worker picks it up + // right away and the NOTIFY trigger fires). + let rollback_job_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO patch_jobs + (kind, status, created_by_user_id, parent_job_id, immediate, + patch_selection, notes) + VALUES + ('rollback'::job_kind, 'queued'::job_status, $1, $2, TRUE, + '[]'::jsonb, $3) + RETURNING id + "#, + ) + .bind(auth.user_id) + .bind(id) + .bind(format!("Rollback of job {}", id)) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, parent_job_id = %id, "rollback_job: insert failed"); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + + // Replicate host list into the rollback job. + for host_id in &host_ids { + sqlx::query( + r#" + INSERT INTO patch_job_hosts (job_id, host_id, status) + VALUES ($1, $2, 'queued'::job_status) + ON CONFLICT (job_id, host_id) DO NOTHING + "#, + ) + .bind(rollback_job_id) + .bind(host_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!( + error = %e, %rollback_job_id, %host_id, + "rollback_job: insert patch_job_hosts failed" + ); + err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error") + })?; + } + + log_event( + &state.db, + AuditAction::PatchJobRollback, + Some(auth.user_id), + Some(&auth.username), + Some("job"), + Some(&rollback_job_id.to_string()), + json!({ + "original_job_id": id, + "rollback_job_id": rollback_job_id, + "host_count": host_ids.len(), + }), + None, + None, + ) + .await; + + tracing::info!( + rollback_job_id = %rollback_job_id, + original_job_id = %id, + user = %auth.username, + "Rollback job created" + ); + + Ok(Json(json!({ + "id": rollback_job_id, + "parent_job_id": id, + "message": "Rollback job created" + }))) +} diff --git a/crates/pm-web/src/routes/mod.rs b/crates/pm-web/src/routes/mod.rs index 6acc04e..32c3b76 100644 --- a/crates/pm-web/src/routes/mod.rs +++ b/crates/pm-web/src/routes/mod.rs @@ -3,4 +3,6 @@ pub mod auth; pub mod discovery; pub mod groups; pub mod hosts; +pub mod jobs; +pub mod status; pub mod users; diff --git a/crates/pm-web/src/routes/status.rs b/crates/pm-web/src/routes/status.rs new file mode 100644 index 0000000..a9eac68 --- /dev/null +++ b/crates/pm-web/src/routes/status.rs @@ -0,0 +1,151 @@ +//! Fleet status routes. +//! +//! GET /api/v1/status/fleet — aggregate health and patch summary across all hosts. + +use axum::{ + extract::State, + http::StatusCode, + response::Json, + routing::get, + Router, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::AppState; + +pub fn router() -> Router { + Router::new().route("/fleet", get(fleet_status)) +} + +// ── Response type ───────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct FleetStatus { + pub total_hosts: i64, + pub healthy: i64, + pub degraded: i64, + pub unreachable: i64, + pub pending: i64, + pub total_pending_patches: i64, + pub hosts_requiring_reboot: i64, + pub compliance_pct: f64, +} + +// ── GET /api/v1/status/fleet ────────────────────────────────────────────────── + +pub async fn fleet_status( + State(state): State, +) -> Result, (StatusCode, Json)> { + // ── 1. Host health aggregates ───────────────────────────────────────── + let health_row: (i64, i64, i64, i64, i64) = sqlx::query_as( + r#" + SELECT + COUNT(*) AS total_hosts, + COUNT(*) FILTER (WHERE health_status = 'healthy') AS healthy, + COUNT(*) FILTER (WHERE health_status = 'degraded') AS degraded, + COUNT(*) FILTER (WHERE health_status = 'unreachable') AS unreachable, + COUNT(*) FILTER (WHERE health_status = 'pending') AS pending + FROM hosts + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "fleet_status: failed to query host health aggregates"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + + let (total_hosts, healthy, degraded, unreachable, pending) = health_row; + + // ── 2. Total pending patches across fleet (latest row per host) ─────── + let total_pending_patches: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(patch_count), 0) + FROM ( + SELECT DISTINCT ON (host_id) patch_count + FROM host_patch_data + ORDER BY host_id, polled_at DESC + ) latest + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "fleet_status: failed to query total pending patches"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + + // ── 3. Hosts requiring a reboot (latest patch row per host) ─────────── + let hosts_requiring_reboot: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ( + SELECT DISTINCT ON (host_id) available_patches + FROM host_patch_data + ORDER BY host_id, polled_at DESC + ) latest + WHERE available_patches @> '[{"requires_reboot": true}]' + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "fleet_status: failed to query reboot-required hosts"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + + // ── 4. Compliance: hosts with zero pending patches / total hosts ─────── + // Hosts that have been polled and have patch_count == 0 are considered + // compliant. Hosts with no patch data at all are excluded from the + // compliance calculation. + let compliant_hosts: i64 = sqlx::query_scalar( + r#" + SELECT COUNT(*) + FROM ( + SELECT DISTINCT ON (host_id) patch_count + FROM host_patch_data + ORDER BY host_id, polled_at DESC + ) latest + WHERE patch_count = 0 + "#, + ) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "fleet_status: failed to query compliant hosts"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) + })?; + + let compliance_pct = if total_hosts == 0 { + 100.0_f64 + } else { + (compliant_hosts as f64 / total_hosts as f64) * 100.0 + }; + + // Round to one decimal place. + let compliance_pct = (compliance_pct * 10.0).round() / 10.0; + + Ok(Json(FleetStatus { + total_hosts, + healthy, + degraded, + unreachable, + pending, + total_pending_patches, + hosts_requiring_reboot, + compliance_pct, + })) +} diff --git a/crates/pm-worker/Cargo.toml b/crates/pm-worker/Cargo.toml index 6022011..91ee25f 100644 --- a/crates/pm-worker/Cargo.toml +++ b/crates/pm-worker/Cargo.toml @@ -11,7 +11,8 @@ path = "src/main.rs" [dependencies] pm-core = { path = "../pm-core" } -tokio = { workspace = true } +pm-agent-client = { path = "../pm-agent-client" } +tokio = { workspace = true, features = ["full"] } sqlx = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/pm-worker/src/agent_loader.rs b/crates/pm-worker/src/agent_loader.rs new file mode 100644 index 0000000..812a723 --- /dev/null +++ b/crates/pm-worker/src/agent_loader.rs @@ -0,0 +1,45 @@ +//! Helper for loading mTLS certificate/key material from disk. +//! +//! Reads PEM files referenced in [`SecurityConfig`] and returns the raw bytes +//! needed by [`pm_agent_client::AgentClient`]. + +use pm_core::config::SecurityConfig; + +/// Raw PEM bytes for mTLS client authentication and CA verification. +pub struct AgentCerts { + pub client_cert: Vec, + pub client_key: Vec, + pub ca_cert: Vec, +} + +/// Load agent mTLS certificates from the paths specified in [`SecurityConfig`]. +/// +/// Returns an error if any file cannot be read. The caller should handle +/// the error gracefully (log and skip the poll cycle) rather than crashing. +pub fn load_agent_certs(security: &SecurityConfig) -> anyhow::Result { + let client_cert = std::fs::read(&security.agent_client_cert_path).map_err(|e| { + anyhow::anyhow!( + "Failed to read agent client cert '{}': {}", + security.agent_client_cert_path, + e + ) + })?; + + let client_key = std::fs::read(&security.agent_client_key_path).map_err(|e| { + anyhow::anyhow!( + "Failed to read agent client key '{}': {}", + security.agent_client_key_path, + e + ) + })?; + + let ca_cert = std::fs::read(&security.ca_cert_path).map_err(|e| { + anyhow::anyhow!( + "Failed to read CA cert '{}': {}", + security.ca_cert_path, + e + ) + })?; + + Ok(AgentCerts { client_cert, client_key, ca_cert }) +} diff --git a/crates/pm-worker/src/health_poller.rs b/crates/pm-worker/src/health_poller.rs new file mode 100644 index 0000000..46cd76c --- /dev/null +++ b/crates/pm-worker/src/health_poller.rs @@ -0,0 +1,202 @@ +//! Periodic health poller for all registered hosts. +//! +//! Polls every host via the agent `/health` endpoint on each tick of +//! `health_poll_interval_secs`, with bounded concurrency controlled by a +//! [`tokio::sync::Semaphore`]. + +use std::sync::Arc; + +use pm_agent_client::{AgentClient, AgentClientError}; +use pm_core::{ + config::AppConfig, + models::HostHealthStatus, +}; +use sqlx::{FromRow, PgPool}; +use tokio::{ + sync::Semaphore, + time, +}; +use uuid::Uuid; + +use crate::agent_loader::load_agent_certs; + +/// Minimal host projection fetched for each poll cycle. +#[derive(Debug, FromRow)] +struct HostRow { + id: Uuid, + ip_address: String, + agent_port: i32, +} + +/// Run the health poller loop indefinitely. +/// +/// On each tick all registered hosts are queried concurrently (up to +/// `max_concurrent_agent_calls` in-flight at once). Results are persisted +/// to `host_health_data` and the `hosts` table is updated. +pub async fn run_health_poller(pool: PgPool, config: Arc) { + let interval_secs = config.worker.health_poll_interval_secs; + let mut ticker = time::interval(std::time::Duration::from_secs(interval_secs)); + + tracing::info!( + interval_secs, + "Health poller started" + ); + + loop { + ticker.tick().await; + + // Load certs on each cycle so cert rotation is picked up automatically. + let certs = match load_agent_certs(&config.security) { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "Health poller: failed to load agent certs — skipping cycle"); + continue; + } + }; + + let client_cert = Arc::new(certs.client_cert); + let client_key = Arc::new(certs.client_key); + let ca_cert = Arc::new(certs.ca_cert); + + // Fetch all hosts. + let hosts: Vec = match sqlx::query_as( + "SELECT id, ip_address::text AS ip_address, agent_port FROM hosts ORDER BY id", + ) + .fetch_all(&pool) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::error!(error = %e, "Health poller: failed to fetch hosts"); + continue; + } + }; + + if hosts.is_empty() { + tracing::debug!("Health poller: no hosts registered, skipping cycle"); + continue; + } + + let total = hosts.len(); + let semaphore = Arc::new(Semaphore::new(config.worker.max_concurrent_agent_calls)); + + let mut handles = Vec::with_capacity(total); + + for host in hosts { + let pool = pool.clone(); + let sem = semaphore.clone(); + let cert = client_cert.clone(); + let key = client_key.clone(); + let ca = ca_cert.clone(); + + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + poll_host_health(pool, host, &cert, &key, &ca).await + }); + + handles.push(handle); + } + + // Collect results and tally counts. + let mut healthy = 0usize; + let mut degraded = 0usize; + let mut unreachable = 0usize; + + for handle in handles { + match handle.await { + Ok(HostHealthStatus::Healthy) => healthy += 1, + Ok(HostHealthStatus::Degraded) => degraded += 1, + Ok(HostHealthStatus::Unreachable) => unreachable += 1, + Ok(_) => {} + Err(e) => tracing::error!(error = %e, "Health poller task panicked"), + } + } + + tracing::info!( + total, + healthy, + degraded, + unreachable, + "Health poll cycle complete" + ); + } +} + +/// Poll a single host, persist the result, and return the determined status. +async fn poll_host_health( + pool: PgPool, + host: HostRow, + client_cert: &[u8], + client_key: &[u8], + ca_cert: &[u8], +) -> HostHealthStatus { + // Determine status and optional health payload. + let (status, payload) = match AgentClient::new( + &host.ip_address, + host.agent_port as u16, + client_cert, + client_key, + ca_cert, + ) { + Err(e) => { + tracing::warn!( + host_id = %host.id, + error = %e, + "Health poller: failed to build AgentClient" + ); + (HostHealthStatus::Unreachable, serde_json::Value::Object(Default::default())) + } + Ok(client) => match client.health().await { + Ok(data) => { + let payload = serde_json::to_value(&data).unwrap_or_default(); + (HostHealthStatus::Healthy, payload) + } + Err(AgentClientError::Timeout) => { + tracing::warn!(host_id = %host.id, "Health poller: agent timed out"); + (HostHealthStatus::Unreachable, serde_json::Value::Object(Default::default())) + } + Err(AgentClientError::Connect(_)) => { + tracing::warn!(host_id = %host.id, "Health poller: agent connection refused"); + (HostHealthStatus::Unreachable, serde_json::Value::Object(Default::default())) + } + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Health poller: agent error"); + (HostHealthStatus::Degraded, serde_json::Value::Object(Default::default())) + } + }, + }; + + // Insert into host_health_data. + if let Err(e) = sqlx::query( + r#" + INSERT INTO host_health_data (host_id, status, payload) + VALUES ($1, $2, $3) + "#, + ) + .bind(host.id) + .bind(&status) + .bind(&payload) + .execute(&pool) + .await + { + tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to insert health data"); + } + + // Update hosts table. + if let Err(e) = sqlx::query( + r#" + UPDATE hosts + SET health_status = $2, last_health_at = NOW() + WHERE id = $1 + "#, + ) + .bind(host.id) + .bind(&status) + .execute(&pool) + .await + { + tracing::error!(host_id = %host.id, error = %e, "Health poller: failed to update host status"); + } + + status +} diff --git a/crates/pm-worker/src/job_executor.rs b/crates/pm-worker/src/job_executor.rs new file mode 100644 index 0000000..e643479 --- /dev/null +++ b/crates/pm-worker/src/job_executor.rs @@ -0,0 +1,826 @@ +//! Job execution engine. +//! +//! Picks up patch jobs from the database, dispatches them to agents via mTLS, +//! tracks progress, and handles retries with exponential back-off. +//! +//! Two concurrent loops run inside [`run_job_executor`]: +//! +//! 1. **NOTIFY listener** — listens on `job_enqueued`; triggers immediate +//! dispatch for newly-enqueued jobs. +//! 2. **Periodic scanner** — every 60 seconds: +//! - picks up queued non-immediate jobs that were missed by NOTIFY, +//! - polls running agent jobs for completion, +//! - retries pending host jobs whose back-off window has elapsed. + +use std::sync::Arc; + +use chrono::{Duration as ChronoDuration, Utc}; +use pm_agent_client::{AgentClient, types::ApplyPatchesRequest}; +use pm_core::config::AppConfig; +use sqlx::{FromRow, PgPool}; +use tokio::{sync::Semaphore, time}; +use uuid::Uuid; + +use crate::agent_loader::load_agent_certs; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal DB row types +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, FromRow)] +#[allow(dead_code)] +struct PatchJobHostQueued { + id: Uuid, + host_id: Uuid, + job_id: Uuid, +} + +#[derive(Debug, FromRow)] +struct PatchJobHostRunning { + id: Uuid, + agent_job_id: String, + job_id: Uuid, + ip_address: String, + agent_port: i32, +} + +#[derive(Debug, FromRow)] +struct PatchJobHostPending { + id: Uuid, + host_id: Uuid, + job_id: Uuid, +} + +#[derive(Debug, FromRow)] +struct HostRow { + ip_address: String, + agent_port: i32, +} + +#[derive(Debug, FromRow)] +struct JobPatchSelection { + patch_selection: serde_json::Value, +} + +#[derive(Debug, FromRow)] +struct RetryRow { + job_id: Uuid, + retry_count: i32, +} + +#[derive(Debug, FromRow)] +struct StatusCounts { + running_count: i64, + pending_count: i64, + queued_count: i64, + succeeded_count: i64, + failed_count: i64, + cancelled_count: i64, + total_count: i64, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public entry point +// ───────────────────────────────────────────────────────────────────────────── + +/// Spawn the job executor and run it indefinitely. +/// +/// Runs two independent tasks joined until both complete (they never do under +/// normal operation): +/// - NOTIFY-driven immediate dispatch (auto-reconnect on DB disconnect). +/// - 60-second periodic scanner for queued / running / pending rows. +pub async fn run_job_executor(pool: PgPool, config: Arc) { + tracing::info!("Job executor started"); + + let (pool_n, cfg_n) = (pool.clone(), config.clone()); + let (pool_s, cfg_s) = (pool.clone(), config.clone()); + + let notify_task = tokio::spawn(async move { + run_notify_listener(pool_n, cfg_n).await; + }); + let scan_task = tokio::spawn(async move { + run_periodic_scanner(pool_s, cfg_s).await; + }); + + let _ = tokio::join!(notify_task, scan_task); +} + +// ───────────────────────────────────────────────────────────────────────────── +// NOTIFY listener (outer reconnect wrapper) +// ───────────────────────────────────────────────────────────────────────────── + +async fn run_notify_listener(pool: PgPool, config: Arc) { + tracing::info!("Job executor NOTIFY listener starting"); + loop { + if let Err(e) = notify_listen_loop(&pool, &config).await { + tracing::error!( + error = %e, + "Job executor NOTIFY listener disconnected, reconnecting in 5s" + ); + time::sleep(std::time::Duration::from_secs(5)).await; + } + } +} + +/// Inner NOTIFY loop — returns `Err` only on a fatal connection error so the +/// outer loop can reconnect. +async fn notify_listen_loop( + pool: &PgPool, + config: &Arc, +) -> anyhow::Result<()> { + let mut listener = + sqlx::postgres::PgListener::connect(&config.database.url).await?; + listener.listen("job_enqueued").await?; + tracing::debug!("Job executor NOTIFY listener connected"); + + loop { + let notification = listener.recv().await?; + let payload = notification.payload().to_string(); + tracing::info!(payload, "job_enqueued notification received"); + + let job_id = match payload.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!( + payload, + error = %e, + "Job executor: invalid UUID in job_enqueued payload" + ); + continue; + } + }; + + let (p, c) = (pool.clone(), config.clone()); + tokio::spawn(async move { + process_job(p, c, job_id).await; + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Periodic scanner +// ───────────────────────────────────────────────────────────────────────────── + +async fn run_periodic_scanner(pool: PgPool, config: Arc) { + // First tick fires immediately — consume it to avoid a duplicate burst + // right after NOTIFY already dispatched the same jobs. + let mut ticker = time::interval(std::time::Duration::from_secs(60)); + ticker.tick().await; + + loop { + ticker.tick().await; + tracing::debug!("Job executor periodic scan starting"); + + // 1. Pick up queued pjh rows that belong to non-cancelled jobs. + scan_queued_jobs(pool.clone(), config.clone()).await; + + // 2. Poll running pjh rows against the agent. + poll_running_jobs(pool.clone(), config.clone()).await; + + // 3. Retry pending pjh rows whose back-off window has elapsed. + retry_pending_jobs(pool.clone(), config.clone()).await; + + tracing::debug!("Job executor periodic scan complete"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// scan_queued_jobs — feeds non-immediate jobs into process_job +// ───────────────────────────────────────────────────────────────────────────── + +/// Discover distinct job-IDs that have queued host entries ready for dispatch +/// and call [`process_job`] for each. +async fn scan_queued_jobs(pool: PgPool, config: Arc) { + #[derive(FromRow)] + struct JobIdRow { + job_id: Uuid, + } + + let rows: Vec = match sqlx::query_as( + r#" + SELECT DISTINCT pjh.job_id + FROM patch_job_hosts pjh + JOIN patch_jobs j ON j.id = pjh.job_id + WHERE pjh.status = 'queued' + AND (pjh.retry_next_at IS NULL OR pjh.retry_next_at <= NOW()) + AND j.status != 'cancelled' + "#, + ) + .fetch_all(&pool) + .await + { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "scan_queued_jobs: DB query failed"); + return; + } + }; + + for row in rows { + let (p, c) = (pool.clone(), config.clone()); + tokio::spawn(async move { + process_job(p, c, row.job_id).await; + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// process_job +// ───────────────────────────────────────────────────────────────────────────── + +/// Fetch all queued host entries for `job_id` and dispatch them concurrently, +/// bounded by `config.worker.max_concurrent_agent_calls`. +async fn process_job(pool: PgPool, config: Arc, job_id: Uuid) { + tracing::info!(%job_id, "process_job: dispatching queued hosts"); + + // Mark the parent job as running (idempotent guard). + if let Err(e) = sqlx::query( + r#" + UPDATE patch_jobs + SET status = 'running', + started_at = COALESCE(started_at, NOW()) + WHERE id = $1 + AND status NOT IN ('running','succeeded','failed','cancelled') + "#, + ) + .bind(job_id) + .execute(&pool) + .await + { + tracing::error!(%job_id, error = %e, "process_job: failed to mark job running"); + } + + // Fetch all queued host entries for this job. + let hosts: Vec = match sqlx::query_as( + r#" + SELECT id, host_id, job_id + FROM patch_job_hosts + WHERE job_id = $1 + AND status = 'queued' + "#, + ) + .bind(job_id) + .fetch_all(&pool) + .await + { + Ok(h) => h, + Err(e) => { + tracing::error!(%job_id, error = %e, "process_job: failed to fetch queued hosts"); + return; + } + }; + + if hosts.is_empty() { + tracing::debug!(%job_id, "process_job: no queued hosts found (already dispatched)"); + return; + } + + let sem = Arc::new(Semaphore::new(config.worker.max_concurrent_agent_calls)); + + for host in hosts { + let permit = match sem.clone().acquire_owned().await { + Ok(p) => p, + Err(e) => { + tracing::error!(%job_id, error = %e, "process_job: semaphore closed"); + break; + } + }; + + let (p, c) = (pool.clone(), config.clone()); + let pjh_id = host.id; + let host_id = host.host_id; + + tokio::spawn(async move { + execute_host_job(p, c, job_id, host_id, pjh_id).await; + drop(permit); + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute_host_job +// ───────────────────────────────────────────────────────────────────────────── + +/// Connect to a single host agent, submit the patch job, and record the +/// agent-assigned async job ID for later polling. +async fn execute_host_job( + pool: PgPool, + config: Arc, + job_id: Uuid, + host_id: Uuid, + pjh_id: Uuid, +) { + tracing::info!(%job_id, %host_id, %pjh_id, "execute_host_job: starting"); + + // ── 1. Fetch host connection details ───────────────────────────────────── + let host: HostRow = match sqlx::query_as( + "SELECT ip_address::text AS ip_address, agent_port FROM hosts WHERE id = $1", + ) + .bind(host_id) + .fetch_optional(&pool) + .await + { + Ok(Some(h)) => h, + Ok(None) => { + tracing::error!(%host_id, "execute_host_job: host not found"); + handle_host_failure( + pool, + pjh_id, + format!("Host {host_id} not found in database"), + ) + .await; + return; + } + Err(e) => { + tracing::error!(%host_id, error = %e, "execute_host_job: DB error fetching host"); + handle_host_failure(pool, pjh_id, format!("DB error fetching host: {e}")).await; + return; + } + }; + + // ── 2. Fetch the job's patch_selection ────────────────────────────────── + let patch_sel: JobPatchSelection = match sqlx::query_as( + "SELECT patch_selection FROM patch_jobs WHERE id = $1", + ) + .bind(job_id) + .fetch_optional(&pool) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + tracing::error!(%job_id, "execute_host_job: parent job not found"); + handle_host_failure(pool, pjh_id, format!("Parent job {job_id} not found")).await; + return; + } + Err(e) => { + tracing::error!(%job_id, error = %e, "execute_host_job: DB error fetching job"); + handle_host_failure(pool, pjh_id, format!("DB error fetching job: {e}")).await; + return; + } + }; + + let packages: Vec = + serde_json::from_value(patch_sel.patch_selection).unwrap_or_default(); + + // ── 3. Load mTLS certs ─────────────────────────────────────────────────── + let certs = match load_agent_certs(&config.security) { + Ok(c) => c, + Err(e) => { + tracing::error!(%host_id, error = %e, "execute_host_job: failed to load agent certs"); + handle_host_failure(pool, pjh_id, format!("Failed to load agent certs: {e}")).await; + return; + } + }; + + // ── 4. Build AgentClient ───────────────────────────────────────────────── + let client = match AgentClient::new( + &host.ip_address, + host.agent_port as u16, + &certs.client_cert, + &certs.client_key, + &certs.ca_cert, + ) { + Ok(c) => c, + Err(e) => { + tracing::error!(%host_id, error = %e, "execute_host_job: failed to build AgentClient"); + handle_host_failure(pool, pjh_id, format!("Failed to build agent client: {e}")).await; + return; + } + }; + + // ── 5. Mark pjh as running ─────────────────────────────────────────────── + if let Err(e) = sqlx::query( + r#" + UPDATE patch_job_hosts + SET status = 'running', + started_at = COALESCE(started_at, NOW()) + WHERE id = $1 + "#, + ) + .bind(pjh_id) + .execute(&pool) + .await + { + tracing::error!(%pjh_id, error = %e, "execute_host_job: failed to mark pjh running"); + } + + // ── 6. Submit the patch job to the agent ───────────────────────────────── + let req = ApplyPatchesRequest { packages, allow_reboot: true }; + + match client.apply_patches(&req).await { + Ok(resp) => { + tracing::info!( + %pjh_id, + agent_job_id = %resp.job_id, + "execute_host_job: agent accepted job" + ); + + // ── 7. Store agent_job_id; status stays 'running' (agent is async) ── + if let Err(e) = sqlx::query( + "UPDATE patch_job_hosts SET agent_job_id = $1 WHERE id = $2", + ) + .bind(&resp.job_id) + .bind(pjh_id) + .execute(&pool) + .await + { + tracing::error!( + %pjh_id, + error = %e, + "execute_host_job: failed to store agent_job_id" + ); + } + } + Err(e) => { + tracing::warn!(%pjh_id, error = %e, "execute_host_job: agent rejected job"); + handle_host_failure(pool, pjh_id, format!("Agent error: {e}")).await; + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// poll_running_jobs +// ───────────────────────────────────────────────────────────────────────────── + +/// Poll all running pjh rows that have an agent job ID and update their status. +pub async fn poll_running_jobs(pool: PgPool, config: Arc) { + let rows: Vec = match sqlx::query_as( + r#" + SELECT pjh.id, + pjh.agent_job_id, + pjh.job_id, + h.ip_address::text AS ip_address, + h.agent_port + FROM patch_job_hosts pjh + JOIN hosts h ON h.id = pjh.host_id + WHERE pjh.status = 'running' + AND pjh.agent_job_id IS NOT NULL + "#, + ) + .fetch_all(&pool) + .await + { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "poll_running_jobs: DB query failed"); + return; + } + }; + + for row in rows { + let (p, c) = (pool.clone(), config.clone()); + tokio::spawn(async move { + poll_single_host(p, c, row).await; + }); + } +} + +/// Poll one running host entry and update its status from the agent response. +async fn poll_single_host( + pool: PgPool, + config: Arc, + row: PatchJobHostRunning, +) { + let certs = match load_agent_certs(&config.security) { + Ok(c) => c, + Err(e) => { + tracing::error!( + pjh_id = %row.id, + error = %e, + "poll_single_host: failed to load agent certs" + ); + return; + } + }; + + let client = match AgentClient::new( + &row.ip_address, + row.agent_port as u16, + &certs.client_cert, + &certs.client_key, + &certs.ca_cert, + ) { + Ok(c) => c, + Err(e) => { + tracing::error!( + pjh_id = %row.id, + error = %e, + "poll_single_host: failed to build AgentClient" + ); + return; + } + }; + + let status = match client.job_status(&row.agent_job_id).await { + Ok(s) => s, + Err(e) => { + tracing::warn!( + pjh_id = %row.id, + agent_job_id = %row.agent_job_id, + error = %e, + "poll_single_host: agent status call failed" + ); + return; + } + }; + + match status.status.as_str() { + "succeeded" => { + tracing::info!(pjh_id = %row.id, "poll_single_host: agent job succeeded"); + if let Err(e) = sqlx::query( + r#" + UPDATE patch_job_hosts + SET status = 'succeeded', + completed_at = NOW(), + output = $2 + WHERE id = $1 + "#, + ) + .bind(row.id) + .bind(status.output.as_deref()) + .execute(&pool) + .await + { + tracing::error!(pjh_id = %row.id, error = %e, "poll_single_host: update failed"); + } + sync_job_status(&pool, row.job_id).await; + } + "failed" => { + tracing::warn!(pjh_id = %row.id, "poll_single_host: agent job failed"); + let err_msg = status + .error + .unwrap_or_else(|| "Agent reported failure (no detail)".to_string()); + handle_host_failure(pool, row.id, err_msg).await; + } + "running" | "queued" => { + // Still in progress — nothing to update; will poll again next cycle. + tracing::debug!( + pjh_id = %row.id, + agent_status = %status.status, + "poll_single_host: job still in progress" + ); + } + other => { + tracing::warn!( + pjh_id = %row.id, + agent_status = %other, + "poll_single_host: unexpected agent status — ignoring" + ); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// handle_host_failure +// ───────────────────────────────────────────────────────────────────────────── + +/// Apply exponential back-off retry logic to a failed host job entry. +/// +/// Retries up to 3 times (1 min / 5 min / 30 min delays). After the third +/// failure the entry is marked `failed` and the parent job status is synced. +async fn handle_host_failure(pool: PgPool, pjh_id: Uuid, error_msg: String) { + let row: Option = match sqlx::query_as( + "SELECT job_id, retry_count FROM patch_job_hosts WHERE id = $1", + ) + .bind(pjh_id) + .fetch_optional(&pool) + .await + { + Ok(r) => r, + Err(e) => { + tracing::error!(%pjh_id, error = %e, "handle_host_failure: DB error fetching retry row"); + return; + } + }; + + let row = match row { + Some(r) => r, + None => { + tracing::error!(%pjh_id, "handle_host_failure: pjh row not found"); + return; + } + }; + + if row.retry_count < 3 { + let new_retry_count = row.retry_count + 1; + let retry_next_at = Utc::now() + + match new_retry_count { + 1 => ChronoDuration::minutes(1), + 2 => ChronoDuration::minutes(5), + _ => ChronoDuration::minutes(30), + }; + + tracing::warn!( + %pjh_id, + retry_count = new_retry_count, + ?retry_next_at, + error = %error_msg, + "handle_host_failure: scheduling retry" + ); + + if let Err(e) = sqlx::query( + r#" + UPDATE patch_job_hosts + SET status = 'pending', + retry_count = $2, + retry_next_at = $3, + last_error = $4 + WHERE id = $1 + "#, + ) + .bind(pjh_id) + .bind(new_retry_count) + .bind(retry_next_at) + .bind(&error_msg) + .execute(&pool) + .await + { + tracing::error!(%pjh_id, error = %e, "handle_host_failure: failed to set pending"); + } + } else { + tracing::warn!( + %pjh_id, + retry_count = row.retry_count, + error = %error_msg, + "handle_host_failure: max retries exceeded, marking failed" + ); + + if let Err(e) = sqlx::query( + r#" + UPDATE patch_job_hosts + SET status = 'failed', + error_message = $2, + completed_at = NOW() + WHERE id = $1 + "#, + ) + .bind(pjh_id) + .bind(&error_msg) + .execute(&pool) + .await + { + tracing::error!(%pjh_id, error = %e, "handle_host_failure: failed to mark pjh failed"); + } + + sync_job_status(&pool, row.job_id).await; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// sync_job_status +// ───────────────────────────────────────────────────────────────────────────── + +/// Roll up `patch_job_hosts` aggregate status into the parent `patch_jobs` row. +/// +/// Logic (in priority order): +/// 1. Any `running` or `pending` hosts → keep parent `running`. +/// 2. All hosts `succeeded` → parent `succeeded`. +/// 3. All hosts `cancelled` → parent `cancelled`. +/// 4. Any `failed` with none still active → parent `failed` (includes partial). +async fn sync_job_status(pool: &PgPool, job_id: Uuid) { + let counts: StatusCounts = match sqlx::query_as( + r#" + SELECT + COUNT(*) FILTER (WHERE status = 'running') AS running_count, + COUNT(*) FILTER (WHERE status = 'pending') AS pending_count, + COUNT(*) FILTER (WHERE status = 'queued') AS queued_count, + COUNT(*) FILTER (WHERE status = 'succeeded') AS succeeded_count, + COUNT(*) FILTER (WHERE status = 'failed') AS failed_count, + COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_count, + COUNT(*) AS total_count + FROM patch_job_hosts + WHERE job_id = $1 + "#, + ) + .bind(job_id) + .fetch_one(pool) + .await + { + Ok(c) => c, + Err(e) => { + tracing::error!(%job_id, error = %e, "sync_job_status: DB query failed"); + return; + } + }; + + // Determine the aggregate status. + let new_status: &str; + let set_completed: bool; + + if counts.running_count > 0 || counts.pending_count > 0 || counts.queued_count > 0 { + // Still work in flight — keep parent running. + new_status = "running"; + set_completed = false; + } else if counts.total_count > 0 && counts.succeeded_count == counts.total_count { + // Every host succeeded. + new_status = "succeeded"; + set_completed = true; + } else if counts.total_count > 0 && counts.cancelled_count == counts.total_count { + // Every host cancelled. + new_status = "cancelled"; + set_completed = true; + } else if counts.failed_count > 0 { + // At least one failure and nothing still active → failed (partial counts too). + new_status = "failed"; + set_completed = true; + } else { + // Fallback: nothing actionable yet. + return; + } + + tracing::info!( + %job_id, + new_status, + running = counts.running_count, + pending = counts.pending_count, + queued = counts.queued_count, + succeeded = counts.succeeded_count, + failed = counts.failed_count, + "sync_job_status: updating parent job" + ); + + let result = if set_completed { + sqlx::query( + r#" + UPDATE patch_jobs + SET status = $2, + completed_at = COALESCE(completed_at, NOW()) + WHERE id = $1 + "#, + ) + .bind(job_id) + .bind(new_status) + .execute(pool) + .await + } else { + sqlx::query( + "UPDATE patch_jobs SET status = $2 WHERE id = $1", + ) + .bind(job_id) + .bind(new_status) + .execute(pool) + .await + }; + + if let Err(e) = result { + tracing::error!(%job_id, error = %e, "sync_job_status: failed to update parent job"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// retry_pending_jobs +// ───────────────────────────────────────────────────────────────────────────── + +/// Find pending host entries whose back-off window has elapsed, reset them to +/// `queued`, and dispatch them immediately. +pub async fn retry_pending_jobs(pool: PgPool, config: Arc) { + let rows: Vec = match sqlx::query_as( + r#" + SELECT pjh.id, pjh.host_id, pjh.job_id + FROM patch_job_hosts pjh + JOIN patch_jobs j ON j.id = pjh.job_id + WHERE pjh.status = 'pending' + AND pjh.retry_next_at <= NOW() + AND j.status != 'cancelled' + "#, + ) + .fetch_all(&pool) + .await + { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "retry_pending_jobs: DB query failed"); + return; + } + }; + + for row in rows { + // Reset to queued so execute_host_job can pick it up cleanly. + if let Err(e) = sqlx::query( + "UPDATE patch_job_hosts SET status = 'queued', retry_next_at = NULL WHERE id = $1", + ) + .bind(row.id) + .execute(&pool) + .await + { + tracing::error!( + pjh_id = %row.id, + error = %e, + "retry_pending_jobs: failed to reset pjh to queued" + ); + continue; + } + + tracing::info!( + pjh_id = %row.id, + job_id = %row.job_id, + "retry_pending_jobs: re-dispatching host job" + ); + + let (p, c) = (pool.clone(), config.clone()); + let (job_id, host_id, pjh_id) = (row.job_id, row.host_id, row.id); + tokio::spawn(async move { + execute_host_job(p, c, job_id, host_id, pjh_id).await; + }); + } +} diff --git a/crates/pm-worker/src/main.rs b/crates/pm-worker/src/main.rs index 2ec990a..c680d25 100644 --- a/crates/pm-worker/src/main.rs +++ b/crates/pm-worker/src/main.rs @@ -3,6 +3,12 @@ //! Handles scheduled polling, job execution, maintenance window scheduling, //! retry logic, email notifications, and data pruning. +mod agent_loader; +mod health_poller; +mod patch_poller; +mod refresh_listener; +mod job_executor; + use pm_core::{ config::AppConfig, db, @@ -12,6 +18,11 @@ use sqlx::PgPool; use std::{sync::Arc, time::Duration}; use tokio::time; +use health_poller::run_health_poller; +use patch_poller::run_patch_poller; +use refresh_listener::run_refresh_listener; +use job_executor::run_job_executor; + /// Minimum number of applied migrations the worker requires before /// accepting work. Prevents the worker from running against a schema /// that hasn't been migrated yet. @@ -51,14 +62,24 @@ async fn main() -> anyhow::Result<()> { config.worker.heartbeat_interval_secs, )); - // TODO M4: spawn health_poller, patch_data_poller - // TODO M5: spawn job_executor - // TODO M6: spawn job_scheduler + // M4: agent health poller, patch data poller, on-demand refresh listener + let health_handle = tokio::spawn(run_health_poller(pool.clone(), config.clone())); + let patch_handle = tokio::spawn(run_patch_poller(pool.clone(), config.clone())); + let refresh_handle = tokio::spawn(run_refresh_listener(pool.clone(), config.clone())); + + // M5: job execution engine + let job_exec_handle = tokio::spawn(run_job_executor(pool.clone(), config.clone())); tracing::info!("Worker tasks started"); // Wait for all tasks (they run indefinitely) - let _ = tokio::join!(heartbeat_handle); + let _ = tokio::join!( + heartbeat_handle, + health_handle, + patch_handle, + refresh_handle, + job_exec_handle, + ); Ok(()) } diff --git a/crates/pm-worker/src/patch_poller.rs b/crates/pm-worker/src/patch_poller.rs new file mode 100644 index 0000000..cf40648 --- /dev/null +++ b/crates/pm-worker/src/patch_poller.rs @@ -0,0 +1,209 @@ +//! Periodic patch-data poller for all registered hosts. +//! +//! Polls every host via the agent `/patches` and `/packages` endpoints on +//! each tick of `patch_poll_interval_secs`, with bounded concurrency +//! controlled by a [`tokio::sync::Semaphore`]. + +use std::sync::Arc; + +use pm_agent_client::AgentClient; +use pm_core::config::AppConfig; +use sqlx::{FromRow, PgPool}; +use tokio::{ + sync::Semaphore, + time, +}; +use uuid::Uuid; + +use crate::agent_loader::load_agent_certs; + +/// Minimal host projection fetched for each poll cycle. +#[derive(Debug, FromRow)] +struct HostRow { + id: Uuid, + ip_address: String, + agent_port: i32, +} + +/// Run the patch poller loop indefinitely. +/// +/// On each tick all registered hosts are queried concurrently (up to +/// `max_concurrent_agent_calls` in-flight at once). Results are persisted +/// to `host_patch_data` and `hosts.last_patch_at` is updated. +pub async fn run_patch_poller(pool: PgPool, config: Arc) { + let interval_secs = config.worker.patch_poll_interval_secs; + let mut ticker = time::interval(std::time::Duration::from_secs(interval_secs)); + + tracing::info!( + interval_secs, + "Patch poller started" + ); + + loop { + ticker.tick().await; + + let certs = match load_agent_certs(&config.security) { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "Patch poller: failed to load agent certs — skipping cycle"); + continue; + } + }; + + let client_cert = Arc::new(certs.client_cert); + let client_key = Arc::new(certs.client_key); + let ca_cert = Arc::new(certs.ca_cert); + + let hosts: Vec = match sqlx::query_as( + "SELECT id, ip_address::text AS ip_address, agent_port FROM hosts ORDER BY id", + ) + .fetch_all(&pool) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::error!(error = %e, "Patch poller: failed to fetch hosts"); + continue; + } + }; + + if hosts.is_empty() { + tracing::debug!("Patch poller: no hosts registered, skipping cycle"); + continue; + } + + let total = hosts.len(); + let semaphore = Arc::new(Semaphore::new(config.worker.max_concurrent_agent_calls)); + + let mut handles = Vec::with_capacity(total); + + for host in hosts { + let pool = pool.clone(); + let sem = semaphore.clone(); + let cert = client_cert.clone(); + let key = client_key.clone(); + let ca = ca_cert.clone(); + + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + poll_host_patches(pool, host, &cert, &key, &ca).await + }); + + handles.push(handle); + } + + let mut succeeded = 0usize; + let mut failed = 0usize; + + for handle in handles { + match handle.await { + Ok(true) => succeeded += 1, + Ok(false) => failed += 1, + Err(e) => { + tracing::error!(error = %e, "Patch poller task panicked"); + failed += 1; + } + } + } + + tracing::info!( + total, + succeeded, + failed, + "Patch poll cycle complete" + ); + } +} + +/// Poll a single host for patch and package data, persist the result. +/// Returns `true` on success, `false` on any error. +async fn poll_host_patches( + pool: PgPool, + host: HostRow, + client_cert: &[u8], + client_key: &[u8], + ca_cert: &[u8], +) -> bool { + let client = match AgentClient::new( + &host.ip_address, + host.agent_port as u16, + client_cert, + client_key, + ca_cert, + ) { + Ok(c) => c, + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Patch poller: failed to build AgentClient"); + return false; + } + }; + + // Fetch patches and packages concurrently. + let (patches_result, packages_result) = + tokio::join!(client.patches(), client.packages_upgradable()); + + let patches_data = match patches_result { + Ok(d) => d, + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Patch poller: patches() failed"); + return false; + } + }; + + let packages_data = match packages_result { + Ok(d) => d, + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Patch poller: packages_upgradable() failed"); + return false; + } + }; + + let available_patches = serde_json::to_value(&patches_data.patches).unwrap_or_default(); + let installed_packages = serde_json::to_value(&packages_data.packages).unwrap_or_default(); + let patch_count = patches_data.total as i32; + let cve_count = patches_data + .patches + .iter() + .filter(|p| !p.cve_ids.is_empty()) + .count() as i32; + + // Insert into host_patch_data. + if let Err(e) = sqlx::query( + r#" + INSERT INTO host_patch_data + (host_id, available_patches, installed_packages, patch_count, cve_count) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(host.id) + .bind(&available_patches) + .bind(&installed_packages) + .bind(patch_count) + .bind(cve_count) + .execute(&pool) + .await + { + tracing::error!(host_id = %host.id, error = %e, "Patch poller: failed to insert patch data"); + return false; + } + + // Update hosts.last_patch_at. + if let Err(e) = sqlx::query( + "UPDATE hosts SET last_patch_at = NOW() WHERE id = $1", + ) + .bind(host.id) + .execute(&pool) + .await + { + tracing::error!(host_id = %host.id, error = %e, "Patch poller: failed to update last_patch_at"); + } + + tracing::debug!( + host_id = %host.id, + patch_count, + cve_count, + "Patch data collected" + ); + + true +} diff --git a/crates/pm-worker/src/refresh_listener.rs b/crates/pm-worker/src/refresh_listener.rs new file mode 100644 index 0000000..e51aaeb --- /dev/null +++ b/crates/pm-worker/src/refresh_listener.rs @@ -0,0 +1,265 @@ +//! On-demand refresh listener. +//! +//! Listens on the PostgreSQL `refresh_requested` NOTIFY channel. When a +//! notification arrives the payload is expected to be a host UUID string. +//! The listener immediately polls that host for health and patch data and +//! persists the results — bypassing the normal poll intervals. + +use std::sync::Arc; + +use pm_agent_client::{AgentClient, AgentClientError}; +use pm_core::{ + config::AppConfig, + models::HostHealthStatus, +}; +use sqlx::{FromRow, PgPool}; +use tokio::time; +use uuid::Uuid; + +use crate::agent_loader::load_agent_certs; + +/// Minimal host row used for on-demand refresh. +#[derive(Debug, FromRow)] +struct HostRow { + id: Uuid, + ip_address: String, + agent_port: i32, +} + +/// Run the LISTEN/NOTIFY refresh listener indefinitely. +/// +/// Automatically reconnects if the underlying PostgreSQL connection drops. +pub async fn run_refresh_listener(pool: PgPool, config: Arc) { + tracing::info!("Refresh listener started — listening on 'refresh_requested'"); + + loop { + if let Err(e) = listen_loop(&pool, &config).await { + tracing::error!( + error = %e, + "Refresh listener disconnected, reconnecting in 5s" + ); + time::sleep(std::time::Duration::from_secs(5)).await; + } + } +} + +/// Inner loop — returns `Err` only on a fatal listener error so the outer +/// loop can reconnect. +async fn listen_loop(pool: &PgPool, config: &AppConfig) -> anyhow::Result<()> { + let mut listener = + sqlx::postgres::PgListener::connect(&config.database.url).await?; + + listener.listen("refresh_requested").await?; + + tracing::debug!("Refresh listener connected and listening"); + + loop { + let notification = listener.recv().await?; + let payload = notification.payload().to_string(); + + tracing::info!(payload, "Refresh notification received"); + + let host_id = match payload.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!( + payload, + error = %e, + "Refresh listener: invalid UUID in notification payload" + ); + continue; + } + }; + + // Fetch the host from the database. + let host: Option = sqlx::query_as( + "SELECT id, ip_address::text AS ip_address, agent_port FROM hosts WHERE id = $1", + ) + .bind(host_id) + .fetch_optional(pool) + .await + .unwrap_or(None); + + let host = match host { + Some(h) => h, + None => { + tracing::warn!(%host_id, "Refresh listener: host not found"); + continue; + } + }; + + // Load certs for this refresh. + let certs = match load_agent_certs(&config.security) { + Ok(c) => c, + Err(e) => { + tracing::error!( + %host_id, + error = %e, + "Refresh listener: failed to load agent certs" + ); + continue; + } + }; + + // Spawn the actual work so the listener loop is not blocked. + let pool_clone = pool.clone(); + let cert = certs.client_cert; + let key = certs.client_key; + let ca = certs.ca_cert; + + tokio::spawn(async move { + refresh_host(pool_clone, host, &cert, &key, &ca).await; + }); + } +} + +/// Perform a full health + patch refresh for one host and persist results. +async fn refresh_host( + pool: PgPool, + host: HostRow, + client_cert: &[u8], + client_key: &[u8], + ca_cert: &[u8], +) { + let client = match AgentClient::new( + &host.ip_address, + host.agent_port as u16, + client_cert, + client_key, + ca_cert, + ) { + Ok(c) => c, + Err(e) => { + tracing::warn!( + host_id = %host.id, + error = %e, + "Refresh: failed to build AgentClient" + ); + persist_health_unreachable(&pool, host.id).await; + return; + } + }; + + // ── Health ──────────────────────────────────────────────────────────── + let (health_status, health_payload) = match client.health().await { + Ok(data) => { + let payload = serde_json::to_value(&data).unwrap_or_default(); + (HostHealthStatus::Healthy, payload) + } + Err(AgentClientError::Timeout) | Err(AgentClientError::Connect(_)) => { + tracing::warn!(host_id = %host.id, "Refresh: agent unreachable"); + (HostHealthStatus::Unreachable, serde_json::Value::Object(Default::default())) + } + Err(e) => { + tracing::warn!(host_id = %host.id, error = %e, "Refresh: health error"); + (HostHealthStatus::Degraded, serde_json::Value::Object(Default::default())) + } + }; + + persist_health(&pool, host.id, &health_status, &health_payload).await; + + // ── Patch data ──────────────────────────────────────────────────────── + let (patches_result, packages_result) = + tokio::join!(client.patches(), client.packages_upgradable()); + + match (patches_result, packages_result) { + (Ok(patches_data), Ok(packages_data)) => { + let available_patches = + serde_json::to_value(&patches_data.patches).unwrap_or_default(); + let installed_packages = + serde_json::to_value(&packages_data.packages).unwrap_or_default(); + let patch_count = patches_data.total as i32; + let cve_count = patches_data + .patches + .iter() + .filter(|p| !p.cve_ids.is_empty()) + .count() as i32; + + if let Err(e) = sqlx::query( + r#" + INSERT INTO host_patch_data + (host_id, available_patches, installed_packages, patch_count, cve_count) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(host.id) + .bind(&available_patches) + .bind(&installed_packages) + .bind(patch_count) + .bind(cve_count) + .execute(&pool) + .await + { + tracing::error!( + host_id = %host.id, + error = %e, + "Refresh: failed to insert patch data" + ); + } else { + let _ = sqlx::query( + "UPDATE hosts SET last_patch_at = NOW() WHERE id = $1", + ) + .bind(host.id) + .execute(&pool) + .await; + + tracing::info!( + host_id = %host.id, + patch_count, + cve_count, + "On-demand refresh complete" + ); + } + } + (Err(e), _) | (_, Err(e)) => { + tracing::warn!( + host_id = %host.id, + error = %e, + "Refresh: failed to collect patch data" + ); + } + } +} + +async fn persist_health_unreachable(pool: &PgPool, host_id: Uuid) { + let status = HostHealthStatus::Unreachable; + let payload = serde_json::Value::Object(Default::default()); + persist_health(pool, host_id, &status, &payload).await; +} + +async fn persist_health( + pool: &PgPool, + host_id: Uuid, + status: &HostHealthStatus, + payload: &serde_json::Value, +) { + if let Err(e) = sqlx::query( + r#" + INSERT INTO host_health_data (host_id, status, payload) + VALUES ($1, $2, $3) + "#, + ) + .bind(host_id) + .bind(status) + .bind(payload) + .execute(pool) + .await + { + tracing::error!( + %host_id, + error = %e, + "Refresh: failed to insert health data" + ); + } + + if let Err(e) = sqlx::query( + "UPDATE hosts SET health_status = $2, last_health_at = NOW() WHERE id = $1", + ) + .bind(host_id) + .bind(status) + .execute(pool) + .await + { + tracing::error!(%host_id, error = %e, "Refresh: failed to update host health_status"); + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8105e64 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4013 @@ +{ + "name": "patch-manager-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "patch-manager-ui", + "version": "0.1.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", + "axios": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.5.3", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^8.30.0", + "@typescript-eslint/parser": "^8.30.0", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.24.0", + "typescript": "^5.8.3", + "vite": "^6.3.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.10.tgz", + "integrity": "sha512-vrOpWRmPJSuwLo23J62wggEm/jvGdzqctej+UOCtgDUz6nZJQuj3ByPccVyaa7eQmwAzUwKN56FQPMKkqbj1GA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.10.tgz", + "integrity": "sha512-Au0ma4NSKGKNiimukj8UT/W1x2Qx6Qwn2RvFGykiSqVLYBNlIOPbjnIMvrwLGLu89EEpTVdu/ys/OduZR+tWqw==", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", + "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.10", + "@mui/system": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.10.tgz", + "integrity": "sha512-j3EZN+zOctxUISvJSmsEPo5o2F8zse4l5vRkBY+ps6UtnL6J7o14kUaI4w7gwo73id9e3cDNMVQK/9BVaMHVBw==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.10", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", + "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.10", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.10.tgz", + "integrity": "sha512-7y2eIfy0h7JPz+Yy4pS+wgV68d46PuuxDqKBN4Q8VlPQSsCAGwroMCV6xWyc7g9dvEp8ZNFsknc59GHWO+r6Ow==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91bf46c..a01632f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,9 @@ import HostsPage from './pages/HostsPage' import HostDetailPage from './pages/HostDetailPage' import GroupsPage from './pages/GroupsPage' import UsersPage from './pages/UsersPage' +import DashboardPage from './pages/DashboardPage' +import PatchDeploymentPage from './pages/PatchDeploymentPage' +import JobsPage from './pages/JobsPage' // Placeholder pages — implemented in later milestones const PlaceholderPage = ({ title }: { title: string }) => ( @@ -35,15 +38,15 @@ function App() { } /> {/* Protected — M3 */} - } /> + } /> } /> } /> } /> } /> {/* Protected — later milestones */} - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e9f7dbb..330f0ee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,7 @@ import axios, { type AxiosError } from 'axios' import type { InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '../store/authStore' +import type { FleetStatus, CreateJobRequest } from '../types' const BASE_URL = '/api/v1' @@ -93,3 +94,32 @@ export const authApi = { verifyMfa: (secretBase32: string, code: string) => apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }), } + +// ── Fleet API functions ────────────────────────────────────────────────────── +export const fleetApi = { + getStatus: () => apiClient.get('/status/fleet'), +} + +// ── Hosts API functions ────────────────────────────────────────────────────── +export const hostsApi = { + list: (params?: Record) => apiClient.get('/hosts', { params }), + get: (id: string) => apiClient.get(`/hosts/${id}`), + delete: (id: string) => apiClient.delete(`/hosts/${id}`), + refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`), +} + +// ── Jobs API ───────────────────────────────────────────────────────────────── +export const jobsApi = { + list: (params?: Record) => apiClient.get('/jobs', { params }), + get: (id: string) => apiClient.get(`/jobs/${id}`), + create: (body: CreateJobRequest) => apiClient.post('/jobs', body), + cancel: (id: string) => apiClient.post(`/jobs/${id}/cancel`), + rollback: (id: string) => apiClient.post(`/jobs/${id}/rollback`), +} + +// ── Patches API (per-host patch listing) ────────────────────────────────────── +export const patchesApi = { + // Returns patches available on a specific host via the manager's proxy + // The backend reads from host_patch_data table (cached from agent poll) + getHostPatches: (hostId: string) => apiClient.get(`/hosts/${hostId}/patches`), +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..97a26ee --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Alert, + Box, + Card, + CardContent, + CircularProgress, + Container, + Grid, + IconButton, + LinearProgress, + Toolbar, + Tooltip, + Typography, +} from '@mui/material' +import { + CheckCircle, + Warning, + Error as ErrorIcon, + HourglassEmpty, + BugReport, + RestartAlt, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import { fleetApi } from '../api/client' +import type { FleetStatus } from '../types' + +// ── StatCard ───────────────────────────────────────────────────────────────── +function StatCard({ + title, + value, + color, + icon, +}: { + title: string + value: number + color: string + icon: React.ReactNode +}) { + return ( + + + + {icon} + + {value} + + + + {title} + + + + ) +} + +// ── DashboardPage ───────────────────────────────────────────────────────────── +export default function DashboardPage() { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await fleetApi.getStatus() + setStatus(res.data) + } catch { + setError('Failed to load fleet status') + } finally { + setLoading(false) + } + }, []) + + // Initial load + useEffect(() => { + load() + }, [load]) + + // Auto-refresh every 60 seconds + useEffect(() => { + const t = setInterval(load, 60_000) + return () => clearInterval(t) + }, [load]) + + return ( + + + + Dashboard + + + + + {loading ? : } + + + + + + {error && ( + + {error} + + )} + + {!loading && !status && !error && ( + No fleet data available. + )} + + {status && ( + + {/* ── Row 1: Status stat cards ── */} + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + + {/* ── Row 2: Compliance bar ── */} + + + + + Compliance + + + {status.compliance_pct.toFixed(1)}% + + + = 90 + ? '#2e7d32' + : status.compliance_pct >= 70 + ? '#ed6c02' + : '#d32f2f', + }, + }} + /> + + {status.total_hosts} total host{status.total_hosts !== 1 ? 's' : ''} in fleet + + + + + {/* ── Row 3: Patches + Reboot ── */} + + + + + + + + {status.total_pending_patches.toLocaleString()} + + + + Pending Patches + + + + + + + + + + + {status.hosts_requiring_reboot.toLocaleString()} + + + + Hosts Requiring Reboot + + + + + + + )} + + ) +} diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx index 474de7d..e2a3b30 100644 --- a/frontend/src/pages/GroupsPage.tsx +++ b/frontend/src/pages/GroupsPage.tsx @@ -50,7 +50,7 @@ export default function GroupsPage() { {groups.map(g => ( - {g.name} + {g.name} {g.description || '—'} {new Date(g.created_at).toLocaleDateString()} diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 4a82cad..739fdd2 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { Alert, Box, Button, Chip, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material' +import { Alert, Box, Button, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material' import { ArrowBack } from '@mui/icons-material' import { apiClient } from '../api/client' @@ -29,7 +29,7 @@ export default function HostDetailPage() { {host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? ( - + {k.replace(/_/g, ' ').toUpperCase()} {String(v)} diff --git a/frontend/src/pages/HostsPage.tsx b/frontend/src/pages/HostsPage.tsx index 24a9217..85e0f22 100644 --- a/frontend/src/pages/HostsPage.tsx +++ b/frontend/src/pages/HostsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { Box, Button, Chip, CircularProgress, Container, IconButton, Paper, Table, TableBody, TableCell, TableContainer, TableHead, @@ -7,6 +7,7 @@ import { import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import { apiClient } from '../api/client' +import { hostsApi } from '../api/client' import type { Host, HostHealthStatus } from '../types' const statusColor = (s: HostHealthStatus) => @@ -18,8 +19,9 @@ export default function HostsPage() { const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') + const [refreshing, setRefreshing] = useState(null) - const load = async () => { + const load = useCallback(async () => { setLoading(true) try { const res = await apiClient.get('/hosts', { params: { limit: 100 } }) @@ -27,6 +29,17 @@ export default function HostsPage() { setTotal(res.data.total) } catch { /* handled by interceptor */ } finally { setLoading(false) } + }, []) + + const handleRefresh = async (e: React.MouseEvent, hostId: string) => { + e.stopPropagation() + setRefreshing(hostId) + try { + await hostsApi.refresh(hostId) + setTimeout(() => { load(); setRefreshing(null) }, 2000) + } catch { + setRefreshing(null) + } } useEffect(() => { load() }, []) @@ -72,6 +85,15 @@ export default function HostsPage() { {h.agent_version ?? '—'} e.stopPropagation()}> + + handleRefresh(e, h.id)}> + {refreshing === h.id + ? + : } + + diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx new file mode 100644 index 0000000..41592ef --- /dev/null +++ b/frontend/src/pages/JobsPage.tsx @@ -0,0 +1,513 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Collapse, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Toolbar, + Tooltip, + Typography, +} from '@mui/material' +import { + Cancel as CancelIcon, + ExpandLess, + ExpandMore, + Refresh as RefreshIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { jobsApi } from '../api/client' +import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost } from '../types' + +// ── Status chip ─────────────────────────────────────────────────────────────── +type ChipColor = 'default' | 'info' | 'warning' | 'success' | 'error' + +function statusColor(status: JobStatus): ChipColor { + const map: Record = { + queued: 'default', + pending: 'info', + running: 'warning', + succeeded: 'success', + failed: 'error', + cancelled: 'default', + } + return map[status] +} + +function StatusChip({ status }: { status: JobStatus }) { + return +} + +// ── Kind label ──────────────────────────────────────────────────────────────── +function kindLabel(kind: JobKind): string { + const map: Record = { + patch_apply: 'Patch Apply', + patch_remove: 'Patch Remove', + reboot: 'Reboot', + rollback: 'Rollback', + } + return map[kind] +} + +// ── Format date ─────────────────────────────────────────────────────────────── +function fmtDate(iso?: string): string { + if (!iso) return '—' + return new Date(iso).toLocaleString() +} + +// ── Per-host detail table ───────────────────────────────────────────────────── +function HostDetailTable({ hosts }: { hosts: PatchJobHost[] }) { + if (hosts.length === 0) { + return ( + + + No host entries for this job. + + + ) + } + return ( + + + + + Host + Status + Agent Job ID + Retries + Error + Started + Completed + + + + {hosts.map((h) => ( + + {h.host_display_name} + + + + + + {h.agent_job_id ?? '—'} + + + {h.retry_count} + + {h.error_message ? ( + + + {h.error_message} + + + ) : ( + '—' + )} + + {fmtDate(h.started_at)} + {fmtDate(h.completed_at)} + + ))} + +
+
+ ) +} + +// ── Expandable job row ──────────────────────────────────────────────────────── +interface JobRowProps { + job: PatchJobSummary + expanded: boolean + onToggle: (id: string) => void + onCancel: (id: string) => void + onRollback: (id: string) => void + cancelLoading: boolean + rollbackLoading: boolean + detail: PatchJob | null + detailLoading: boolean + detailError: string | null +} + +function JobRow({ + job, + expanded, + onToggle, + onCancel, + onRollback, + cancelLoading, + rollbackLoading, + detail, + detailLoading, + detailError, +}: JobRowProps) { + const canCancel = job.status === 'queued' || job.status === 'pending' + const canRollback = job.status === 'succeeded' + + return ( + <> + *': { borderBottom: expanded ? 'none' : undefined } }} + onClick={() => onToggle(job.id)} + > + + { e.stopPropagation(); onToggle(job.id) }}> + {expanded ? : } + + + + + {fmtDate(job.created_at)} + + + {kindLabel(job.kind)} + + + + {job.host_count} + + + {job.succeeded_count} + + + + 0 ? 'error.main' : 'text.primary'} fontWeight={600}> + {job.failed_count} + + + + + + + + {job.notes || '—'} + + + e.stopPropagation()}> + + {canCancel && ( + + + onCancel(job.id)} + > + {cancelLoading ? ( + + ) : ( + + )} + + + + )} + {canRollback && ( + + + onRollback(job.id)} + > + {rollbackLoading ? ( + + ) : ( + + )} + + + + )} + + + + + {/* ── Expandable detail row ── */} + + + + {detailLoading ? ( + + + + ) : detailError ? ( + + {detailError} + + ) : detail ? ( + + ) : null} + + + + + ) +} + +// ── JobsPage ────────────────────────────────────────────────────────────────── +export default function JobsPage() { + const [jobs, setJobs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + + // Expanded row detail state + const [expandedId, setExpandedId] = useState(null) + const [details, setDetails] = useState>({}) + const [detailLoading, setDetailLoading] = useState>({}) + const [detailError, setDetailError] = useState>({}) + + // Action state + const [cancelLoadingId, setCancelLoadingId] = useState(null) + const [rollbackLoadingId, setRollbackLoadingId] = useState(null) + + // Rollback confirm dialog + const [rollbackTargetId, setRollbackTargetId] = useState(null) + const [actionError, setActionError] = useState(null) + + const LIMIT = 25 + + const loadJobs = useCallback(async (newOffset = 0) => { + if (newOffset === 0) { + setLoading(true) + setError(null) + } else { + setLoadingMore(true) + } + try { + const res = await jobsApi.list({ limit: LIMIT, offset: newOffset }) + const data = res.data as { jobs?: PatchJobSummary[]; total?: number } | PatchJobSummary[] + const items: PatchJobSummary[] = Array.isArray(data) ? data : (data.jobs ?? []) + const total: number = Array.isArray(data) ? items.length : (data.total ?? items.length) + if (newOffset === 0) { + setJobs(items) + } else { + setJobs((prev) => [...prev, ...items]) + } + setOffset(newOffset + items.length) + setHasMore(newOffset + items.length < total) + } catch { + if (newOffset === 0) setError('Failed to load jobs') + } finally { + setLoading(false) + setLoadingMore(false) + } + }, []) + + useEffect(() => { + loadJobs(0) + }, [loadJobs]) + + const handleToggleExpand = useCallback(async (id: string) => { + if (expandedId === id) { + setExpandedId(null) + return + } + setExpandedId(id) + if (details[id]) return + setDetailLoading((prev) => ({ ...prev, [id]: true })) + setDetailError((prev) => { const n = { ...prev }; delete n[id]; return n }) + try { + const res = await jobsApi.get(id) + setDetails((prev) => ({ ...prev, [id]: res.data as PatchJob })) + } catch { + setDetailError((prev) => ({ ...prev, [id]: 'Failed to load job detail' })) + } finally { + setDetailLoading((prev) => ({ ...prev, [id]: false })) + } + }, [expandedId, details]) + + const handleCancel = useCallback(async (id: string) => { + setCancelLoadingId(id) + setActionError(null) + try { + await jobsApi.cancel(id) + await loadJobs(0) + } catch { + setActionError(`Failed to cancel job ${id}`) + } finally { + setCancelLoadingId(null) + } + }, [loadJobs]) + + const handleRollbackConfirm = useCallback(async () => { + if (!rollbackTargetId) return + const id = rollbackTargetId + setRollbackTargetId(null) + setRollbackLoadingId(id) + setActionError(null) + try { + await jobsApi.rollback(id) + await loadJobs(0) + } catch { + setActionError(`Failed to rollback job ${id}`) + } finally { + setRollbackLoadingId(null) + } + }, [rollbackTargetId, loadJobs]) + + return ( + + + + Jobs + + + + loadJobs(0)} disabled={loading}> + {loading ? : } + + + + + + {error && ( + + {error} + + )} + + {actionError && ( + setActionError(null)}> + {actionError} + + )} + + + + + + + + Created + Kind + Status + Hosts + Succeeded + Failed + Schedule + Notes + Actions + + + + {loading && jobs.length === 0 ? ( + + + + + + ) : jobs.length === 0 ? ( + + + + No jobs found + + + + ) : ( + jobs.map((job) => ( + setRollbackTargetId(id)} + cancelLoading={cancelLoadingId === job.id} + rollbackLoading={rollbackLoadingId === job.id} + detail={details[job.id] ?? null} + detailLoading={detailLoading[job.id] ?? false} + detailError={detailError[job.id] ?? null} + /> + )) + )} + +
+
+ + {hasMore && ( + + + + )} +
+ + {/* ── Rollback confirm dialog ── */} + setRollbackTargetId(null)} + maxWidth="xs" + fullWidth + > + Confirm Rollback + + + Are you sure you want to rollback job{' '} + {rollbackTargetId}? This will create a new rollback job + that attempts to revert the applied patches. + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/PatchDeploymentPage.tsx b/frontend/src/pages/PatchDeploymentPage.tsx new file mode 100644 index 0000000..c4694c8 --- /dev/null +++ b/frontend/src/pages/PatchDeploymentPage.tsx @@ -0,0 +1,435 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Alert, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Container, + FormControlLabel, + InputAdornment, + Paper, + Step, + StepLabel, + Stepper, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Toolbar, + Typography, +} from '@mui/material' +import { Search as SearchIcon } from '@mui/icons-material' +import { useNavigate } from 'react-router-dom' +import { hostsApi, jobsApi } from '../api/client' +import type { Host, HostHealthStatus } from '../types' + +const STEPS = ['Select Hosts', 'Review & Configure', 'Result'] + +// ── Health status chip ──────────────────────────────────────────────────────── +function HealthChip({ status }: { status: HostHealthStatus }) { + const map: Record = { + healthy: 'success', + degraded: 'warning', + unreachable: 'error', + pending: 'default', + } + return +} + +// ── PatchDeploymentPage ─────────────────────────────────────────────────────── +export default function PatchDeploymentPage() { + const navigate = useNavigate() + const [activeStep, setActiveStep] = useState(0) + + // Step 0 state + const [hosts, setHosts] = useState([]) + const [hostsLoading, setHostsLoading] = useState(true) + const [hostsError, setHostsError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [healthFilter, setHealthFilter] = useState('') + const [selectedIds, setSelectedIds] = useState>(new Set()) + + // Step 1 state + const [immediate, setImmediate] = useState(true) + const [allowReboot, setAllowReboot] = useState(false) + const [notes, setNotes] = useState('') + const [packages, setPackages] = useState('') + + // Step 2 state + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [createdJobId, setCreatedJobId] = useState(null) + + const loadHosts = useCallback(async () => { + setHostsLoading(true) + setHostsError(null) + try { + const res = await hostsApi.list() + const data = res.data as { hosts?: Host[] } | Host[] + setHosts(Array.isArray(data) ? data : (data.hosts ?? [])) + } catch { + setHostsError('Failed to load hosts') + } finally { + setHostsLoading(false) + } + }, []) + + useEffect(() => { + loadHosts() + }, [loadHosts]) + + const filteredHosts = hosts.filter((h) => { + const matchesSearch = + searchQuery === '' || + h.display_name.toLowerCase().includes(searchQuery.toLowerCase()) || + h.fqdn.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesHealth = healthFilter === '' || h.health_status === healthFilter + return matchesSearch && matchesHealth + }) + + const handleToggleHost = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleToggleAll = () => { + if (selectedIds.size === filteredHosts.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(filteredHosts.map((h) => h.id))) + } + } + + const handleDeploy = async () => { + setSubmitting(true) + setSubmitError(null) + try { + const pkgList = packages + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + const res = await jobsApi.create({ + host_ids: Array.from(selectedIds), + packages: pkgList, + immediate, + allow_reboot: allowReboot, + notes: notes.trim() || undefined, + }) + const job = res.data as { id: string } + setCreatedJobId(job.id) + setActiveStep(2) + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : 'Deployment failed. Please try again.' + setSubmitError(msg) + setActiveStep(2) + } finally { + setSubmitting(false) + } + } + + const handleReset = () => { + setActiveStep(0) + setSelectedIds(new Set()) + setImmediate(true) + setAllowReboot(false) + setNotes('') + setPackages('') + setSubmitError(null) + setCreatedJobId(null) + } + + const selectedHosts = hosts.filter((h) => selectedIds.has(h.id)) + + return ( + + + + Patch Deployment + + + + + {STEPS.map((label) => ( + + {label} + + ))} + + + {/* ── Step 0: Select Hosts ── */} + {activeStep === 0 && ( + + + Select Target Hosts + + + {hostsError && ( + + {hostsError} + + )} + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ minWidth: 260 }} + /> + setHealthFilter(e.target.value as HostHealthStatus | '')} + SelectProps={{ native: true }} + sx={{ minWidth: 160 }} + > + + + + + + + + + {hostsLoading ? ( + + + + ) : ( + + + + + + 0 && + filteredHosts.every((h) => selectedIds.has(h.id)) + } + indeterminate={ + filteredHosts.some((h) => selectedIds.has(h.id)) && + !filteredHosts.every((h) => selectedIds.has(h.id)) + } + onChange={handleToggleAll} + disabled={filteredHosts.length === 0} + /> + + Display Name + FQDN + IP Address + Health + OS + + + + {filteredHosts.length === 0 ? ( + + + + No hosts found + + + + ) : ( + filteredHosts.map((host) => ( + handleToggleHost(host.id)} + > + + handleToggleHost(host.id)} + onClick={(e) => e.stopPropagation()} + /> + + {host.display_name} + {host.fqdn} + {host.ip_address} + + + + + {host.os_name ?? host.os_family ?? '—'} + + + )) + )} + +
+
+ )} + + + + {selectedIds.size} host{selectedIds.size !== 1 ? 's' : ''} selected + + + +
+ )} + + {/* ── Step 1: Review & Configure ── */} + {activeStep === 1 && ( + + + Review & Configure + + + + Selected Hosts ({selectedHosts.length}) + + + {selectedHosts.map((h) => ( + handleToggleHost(h.id)} + size="small" + /> + ))} + + + + setImmediate(e.target.checked)} + /> + } + label={ + + + {immediate ? 'Apply Now' : 'Queue for Maintenance Window'} + + + {immediate + ? 'Job will run immediately on the selected hosts' + : 'Job will run during the next scheduled maintenance window'} + + + } + /> + + setAllowReboot(e.target.checked)} + /> + } + label="Allow reboot after patching" + /> + + setPackages(e.target.value)} + fullWidth + helperText="e.g. openssl, curl, libssl1.1" + /> + + setNotes(e.target.value)} + fullWidth + /> + + + + + + + + )} + + {/* ── Step 2: Result ── */} + {activeStep === 2 && ( + + + Deployment Result + + + {createdJobId ? ( + + Deployment job created successfully! + + Job ID: {createdJobId} + + + ) : ( + + Deployment failed + {submitError && ( + + {submitError} + + )} + + )} + + + + {createdJobId && ( + + )} + + + )} +
+ ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7678929..463b52a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -45,3 +45,75 @@ export interface User { is_active: boolean last_login_at?: string } + +export interface FleetStatus { + total_hosts: number + healthy: number + degraded: number + unreachable: number + pending: number + total_pending_patches: number + hosts_requiring_reboot: number + compliance_pct: number +} + +export interface PatchInfo { + name: string + current_version: string + available_version: string + severity: 'critical' | 'high' | 'medium' | 'low' + description: string + cve_ids: string[] + requires_reboot: boolean +} + +export interface PatchJobHost { + id: string + job_id: string + host_id: string + host_display_name: string + status: JobStatus + agent_job_id?: string + retry_count: number + output: string + error_message?: string + retry_next_at?: string + started_at?: string + completed_at?: string +} + +export interface PatchJob { + id: string + kind: JobKind + status: JobStatus + immediate: boolean + patch_selection: string[] + notes: string + created_at: string + started_at?: string + completed_at?: string + hosts: PatchJobHost[] +} + +export interface PatchJobSummary { + id: string + kind: JobKind + status: JobStatus + immediate: boolean + host_count: number + succeeded_count: number + failed_count: number + notes: string + created_at: string + started_at?: string + completed_at?: string +} + +export interface CreateJobRequest { + host_ids: string[] + packages: string[] // empty = all patches + immediate: boolean + maintenance_window_id?: string + allow_reboot?: boolean + notes?: string +} diff --git a/migrations/003_jobs_scheduling.sql b/migrations/003_jobs_scheduling.sql new file mode 100644 index 0000000..e7b145d --- /dev/null +++ b/migrations/003_jobs_scheduling.sql @@ -0,0 +1,44 @@ +-- Migration: 003_jobs_scheduling +-- Description: Retry/scheduling columns for patch_job_hosts, and NOTIFY trigger +-- for immediate patch job dispatch. + +-- ============================================================ +-- Add retry-scheduling columns to patch_job_hosts +-- ============================================================ + +-- When the retry engine should next attempt this host; NULL = not scheduled +ALTER TABLE patch_job_hosts + ADD COLUMN retry_next_at TIMESTAMPTZ; + +-- Last failure reason captured by the worker for display in the UI +ALTER TABLE patch_job_hosts + ADD COLUMN last_error TEXT; + +-- ============================================================ +-- pg_notify trigger: fires when an immediate job is inserted +-- ============================================================ + +CREATE OR REPLACE FUNCTION notify_job_enqueued() + RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.immediate = TRUE THEN + PERFORM pg_notify('job_enqueued', NEW.id::text); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_job_enqueued + AFTER INSERT ON patch_jobs + FOR EACH ROW + EXECUTE FUNCTION notify_job_enqueued(); + +-- ============================================================ +-- Index: efficiently find hosts due for retry +-- ============================================================ + +CREATE INDEX idx_pjh_retry + ON patch_job_hosts (retry_next_at) + WHERE retry_next_at IS NOT NULL; diff --git a/tasks/todo.md b/tasks/todo.md index 07232d4..6720ccf 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -116,32 +116,32 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d ### M4: Agent Communication Layer + Dashboard **Goal:** mTLS client works, health/patch polling operational, dashboard shows fleet status. -- [ ] Implement `pm-agent-client` — Rustls-based mTLS HTTP client with client certificate, TLS 1.3 only -- [ ] Implement agent API calls: `GET /api/v1/health`, `GET /api/v1/system/info`, `GET /api/v1/packages`, `GET /api/v1/patches` -- [ ] Implement worker health poller: 5-minute intervals, bounded concurrency (64 semaphore), update `host_health_data` -- [ ] Implement worker patch data poller: 30-minute intervals, bounded concurrency, update `host_patch_data` -- [ ] Implement on-demand refresh: `POST /api/v1/hosts/{id}/refresh` → `NOTIFY refresh_requested` → worker queries immediately -- [ ] Implement host health status tracking: healthy/degraded/unreachable with timestamps -- [ ] Implement dashboard API: `GET /api/v1/status/fleet` (authenticated, fleet aggregates) -- [ ] Frontend: Dashboard page — compliance %, health summary, pending patches, upcoming windows, root CA download icon -- [ ] Frontend: Real-time health status indicators (green/yellow/red) on host lists -- [ ] Verify: polling works, dashboard shows live fleet data, on-demand refresh works, visual alerts for unhealthy agents +- [x] Implement `pm-agent-client` — Rustls-based mTLS HTTP client with client certificate, TLS 1.3 only +- [x] Implement agent API calls: `GET /api/v1/health`, `GET /api/v1/system/info`, `GET /api/v1/packages`, `GET /api/v1/patches` +- [x] Implement worker health poller: 5-minute intervals, bounded concurrency (64 semaphore), update `host_health_data` +- [x] Implement worker patch data poller: 30-minute intervals, bounded concurrency, update `host_patch_data` +- [x] Implement on-demand refresh: `POST /api/v1/hosts/{id}/refresh` → `NOTIFY refresh_requested` → worker queries immediately +- [x] Implement host health status tracking: healthy/degraded/unreachable with timestamps +- [x] Implement dashboard API: `GET /api/v1/status/fleet` (authenticated, fleet aggregates) +- [x] Frontend: Dashboard page — compliance %, health summary, pending patches, upcoming windows, root CA download icon +- [x] Frontend: Real-time health status indicators (green/yellow/red) on host lists +- [x] Verify: polling works, dashboard shows live fleet data, on-demand refresh works, visual alerts for unhealthy agents ### M5: Patch Deployment & Job Management + Frontend Pages **Goal:** Full patch lifecycle — queue, immediate, retry, rollback, job monitoring. -- [ ] Implement job creation: `POST /api/v1/jobs` (queue for window or apply now) -- [ ] Implement `patch_jobs` and `patch_job_hosts` row creation -- [ ] Implement `NOTIFY job_enqueued` for immediate-apply wake -- [ ] Implement worker job executor: call agent `POST /api/v1/patches/apply`, track async job IDs -- [ ] Implement worker retry engine: exponential backoff (1min, 5min, 30min), 3 retries max -- [ ] Implement patch job auto-retry within maintenance window (1 retry) -- [ ] Implement batch partial failure handling: auto-retry once, then report -- [ ] Implement rollback: `POST /api/v1/jobs/{id}/rollback` → worker calls agent rollback endpoint -- [ ] Implement job status tracking: poll agent `GET /api/v1/jobs/{id}` for running jobs -- [ ] Implement job listing/detail API: `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}` -- [ ] Frontend: Patch Deployment page (select hosts → review patches → queue or apply now) -- [ ] Frontend: Jobs page (job list, per-host status, rollback action) +- [x] Implement job creation: `POST /api/v1/jobs` (queue for window or apply now) +- [x] Implement `patch_jobs` and `patch_job_hosts` row creation +- [x] Implement `NOTIFY job_enqueued` for immediate-apply wake +- [x] Implement worker job executor: call agent `POST /api/v1/patches/apply`, track async job IDs +- [x] Implement worker retry engine: exponential backoff (1min, 5min, 30min), 3 retries max +- [x] Implement patch job auto-retry within maintenance window (1 retry) +- [x] Implement batch partial failure handling: auto-retry once, then report +- [x] Implement rollback: `POST /api/v1/jobs/{id}/rollback` → worker calls agent rollback endpoint +- [x] Implement job status tracking: poll agent `GET /api/v1/jobs/{id}` for running jobs +- [x] Implement job listing/detail API: `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}` +- [x] Frontend: Patch Deployment page (select hosts → review patches → queue or apply now) +- [x] Frontend: Jobs page (job list, per-host status, rollback action) - [ ] Verify: queued job waits for window, immediate job runs now, retry logic works, rollback works, batch partial failures reported ### M6: Maintenance Windows & Scheduling + Frontend Page