diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d989ef8..7ffc944ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Faster console API startup**: blocking subsystem initialization moved off the boot hot path. The agents sandbox runtime image (Docker Hub pull or local Dockerfile build) now warms up in a background task instead of blocking plugin init for minutes on first boot; `temps-blob` (RustFS) and `temps-kv` (Redis) defer container init+start to background tasks; `temps-workspace` adopts existing sandbox containers in the background instead of doing one Docker inspect per active session inline; `temps-external-plugins` no longer waits up to 30s × N for handshake during boot — it registers an empty proxy router immediately and discovery swaps a populated router in when it finishes (route into `/x//...` 404s until then, identical to "no plugin installed"). Console API now becomes ready in roughly the time it takes to run plugin DI + bind the listener, regardless of how many external services or plugins are configured ### Fixed +- **Runtime log viewer no longer freezes on chatty containers + adds Live/Pause/Interval modes**: a high-volume container (e.g. ~1500 lines/sec) used to lock the runtime page because every WebSocket frame triggered a `setState` and a dead `document.querySelectorAll('[id^="search-match-"]')` DOM scan ran on every log line. The viewer now batches incoming lines via `requestAnimationFrame` and caps the visible list at 5,000. A new mode selector (Live / Pause / Every 5s · 30s · 1m) is persisted per project to `localStorage`; **Pause closes the WebSocket** so no bytes are wasted while the user reads what's on screen, and resuming reconnects with `tail=200` for a smooth catch-up. The pending buffer between flushes is bounded at 1,000 lines and surfaces a `dropped` count so users notice when they're sampling a firehose rather than recording it. Also fixed the same per-line setState problem in `useLogStream` (container logs viewer) +- **Cmd+K "Logs" no longer hidden when typing "logs"**: the command palette wraps Fuse.js for fuzzy matching but `cmdk`'s built-in matcher was *also* filtering on `CommandItem.value` (which is `project-nav-/projects/{slug}/runtime` for the project Logs entry). The URL doesn't contain "logs", so cmdk silently dropped it while keeping "Request Logs" (URL contains `request-logs`). Added `shouldFilter={false}` to the `` so Fuse alone decides matches - **Deployment logs WebSocket survives redeploys**: the live log stream on the deployment detail page used to give up the moment it received any normal (1000) WebSocket closure — exactly what happens when a redeploy churns the underlying log source — leaving users staring at a frozen pane. The reconnect path also called `setLogs([])` on every retry, wiping the partial output they had. The hook now reconnects with exponential backoff capped at 10s on every closure (the backend tail is an infinite stream, so any close is unexpected) until the component unmounts, preserves prior log state instead of clearing it, and dedupes by absolute file `line` so the last-1000-lines replay the backend sends after reconnect doesn't show up twice - **Single click no longer creates 15 duplicate projects**: the "Create Project" button was disabled via React state (`isSubmitting`), but state updates aren't synchronous — a fast double-click slipped multiple submissions through before the disabled prop landed, and since the backend has no `(user, name)` uniqueness each request created a separate project. Both `ProjectConfigurator` and `ManualProjectConfigurator` now use a `useRef` synchronous re-entry guard at the top of `handleSubmit` so the second invocation is dropped before any network call - **Project creation cleans up half-created rows on failure**: when `create_project` failed any step after the project row was inserted (default environment, env var inserts, storage-link wiring), the project row was left dangling — the handler returned 500 but a half-initialized project showed up in the user's list, and the real cause was buried inside `ProjectError::Other(format!(...))` so the HTTP status was always 500 even when the underlying problem was a 4xx. Post-insert work is now isolated in `finalize_project_creation`; on any error a single `Entity::delete_by_id` on the project row cascades through the foreign keys to clean up environments, env vars, and service links. This avoids holding any cross-service table locks (which a long-lived transaction would). New typed `ProjectError` variants — `EnvironmentCreationFailed`, `EnvVarCreationFailed`, `StorageLinkFailed`, `SlugConflict` — replace the `Other(String)` flatten so failures preserve the original cause and HTTP status diff --git a/Cargo.lock b/Cargo.lock index 1e737fa85..7c6c47a1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11821,7 +11821,9 @@ dependencies = [ "hyper 1.7.0", "jsonwebtoken", "log", + "mockito", "octocrab", + "rand 0.8.6", "regex", "reqwest", "sea-orm", @@ -12357,6 +12359,7 @@ dependencies = [ "aws-smithy-runtime", "axum 0.8.6", "axum-macros", + "base64 0.22.1", "bollard", "bytes", "chrono", diff --git a/crates/temps-agents/src/services/config_service.rs b/crates/temps-agents/src/services/config_service.rs index 85ee5a5d4..ec34fb7bf 100644 --- a/crates/temps-agents/src/services/config_service.rs +++ b/crates/temps-agents/src/services/config_service.rs @@ -1024,6 +1024,8 @@ mod tests { is_public_repo: false, git_url: None, git_provider_connection_id: if has_git { Some(1) } else { None }, + gitlab_webhook_id: None, + gitlab_webhook_signing_token: None, attack_mode: false, enable_preview_environments: false, source_type: temps_entities::source_type::SourceType::Git, diff --git a/crates/temps-agents/src/services/executor.rs b/crates/temps-agents/src/services/executor.rs index 2d52688c7..2e6173361 100644 --- a/crates/temps-agents/src/services/executor.rs +++ b/crates/temps-agents/src/services/executor.rs @@ -3949,6 +3949,8 @@ mod tests { is_public_repo: false, git_url: None, git_provider_connection_id: Some(5), + gitlab_webhook_id: None, + gitlab_webhook_signing_token: None, attack_mode: false, enable_preview_environments: true, source_type: temps_entities::source_type::SourceType::Git, diff --git a/crates/temps-deployer/src/docker.rs b/crates/temps-deployer/src/docker.rs index f27cc056b..8923cd48a 100644 --- a/crates/temps-deployer/src/docker.rs +++ b/crates/temps-deployer/src/docker.rs @@ -59,6 +59,66 @@ pub struct DockerRuntime { overlay_peers: Option>>>, } +/// Map a POSIX exit code (>= 128 means "killed by signal N - 128") to a human +/// signal name. Returns None for normal-range exit codes. +fn signal_name_from_exit_code(code: i64) -> Option<&'static str> { + if !(128..=192).contains(&code) { + return None; + } + match (code - 128) as i32 { + 1 => Some("SIGHUP"), + 2 => Some("SIGINT"), + 3 => Some("SIGQUIT"), + 4 => Some("SIGILL"), + 6 => Some("SIGABRT"), + 8 => Some("SIGFPE"), + 9 => Some("SIGKILL"), + 11 => Some("SIGSEGV"), + 13 => Some("SIGPIPE"), + 14 => Some("SIGALRM"), + 15 => Some("SIGTERM"), + _ => None, + } +} + +/// Build a short human-readable explanation of why a container is in its +/// current state. Returns None for containers that are still running, have +/// never been started, or exited cleanly with status 0 and no Docker error. +pub(crate) fn build_exit_reason( + status: &ContainerStatus, + oom_killed: Option, + exit_code: Option, + error: Option<&str>, +) -> Option { + let is_terminal = matches!( + status, + ContainerStatus::Exited | ContainerStatus::Dead | ContainerStatus::Stopped + ); + if !is_terminal { + return None; + } + if oom_killed == Some(true) { + return Some(match exit_code { + Some(code) => format!("OOMKilled (exit code {})", code), + None => "OOMKilled".to_string(), + }); + } + if let Some(err) = error.filter(|e| !e.is_empty()) { + return Some(match exit_code { + Some(code) => format!("Error (exit code {}): {}", code, err), + None => format!("Error: {}", err), + }); + } + match exit_code { + Some(0) => None, + Some(code) => Some(match signal_name_from_exit_code(code) { + Some(sig) => format!("Killed by {} (exit code {})", sig, code), + None => format!("Exit code {}", code), + }), + None => None, + } +} + impl DockerRuntime { /// Per-container directory that holds plaintext secret files for bind-mounting /// into `/run/secrets`. Lives outside `TempDir` because Docker keeps the @@ -1491,6 +1551,9 @@ impl ContainerDeployer for DockerRuntime { .await .map_err(|e| DeployerError::ContainerNotFound(format!("Container not found: {}", e)))?; + // Pull host_config out before state/config so we can read the CPU + // limit (nano_cpus) without re-borrowing the moved container value. + let host_config = container.host_config.clone(); let state = container.state.unwrap_or_default(); let config = container.config.unwrap_or_default(); let container_labels = config.labels.clone().unwrap_or_default(); @@ -1541,6 +1604,51 @@ impl ContainerDeployer for DockerRuntime { }) .collect(); + let status = + Self::map_container_status(&state.status.map(|s| s.to_string()).unwrap_or_default()); + + // Capture exit metadata so the API/UI can show *why* a container is + // gone instead of a bare "Exited". Docker only populates these once + // the container has actually exited; for running containers they + // remain None. + let oom_killed = state.oom_killed; + let exit_code_i64 = state.exit_code; + let exit_code: Option = exit_code_i64.and_then(|c| i32::try_from(c).ok()); + // Trim Docker's empty-string "no error" sentinel; only surface a real message. + let error_message = state.error.and_then(|e| { + let trimmed = e.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + // FinishedAt comes back as RFC3339; "0001-01-01T00:00:00Z" means + // never-finished, which we treat as None. + let parse_docker_ts = |s: &str| -> Option> { + if s.is_empty() || s.starts_with("0001-01-01") { + None + } else { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + } + }; + let finished_at = state.finished_at.as_deref().and_then(parse_docker_ts); + let started_at = state.started_at.as_deref().and_then(parse_docker_ts); + + // Translate Docker's nano_cpus to whole-core units so the UI can + // render "0.5 / 1.0 cores" without doing the divide. None when no + // limit was set on the container (host_config absent or nano_cpus 0). + let cpu_limit_cores = host_config + .as_ref() + .and_then(|hc| hc.nano_cpus) + .filter(|nc| *nc > 0) + .map(|nc| nc as f64 / 1_000_000_000.0); + + let exit_reason = + build_exit_reason(&status, oom_killed, exit_code_i64, error_message.as_deref()); + Ok(ContainerInfo { container_id: container.id.unwrap_or_default(), container_name: container @@ -1549,9 +1657,7 @@ impl ContainerDeployer for DockerRuntime { .trim_start_matches('/') .to_string(), image_name: config.image.unwrap_or_default(), - status: Self::map_container_status( - &state.status.map(|s| s.to_string()).unwrap_or_default(), - ), + status, created_at: container .created .and_then(|dt| { @@ -1562,6 +1668,13 @@ impl ContainerDeployer for DockerRuntime { environment_vars: env_vars, restart_count: container.restart_count, labels: container_labels, + exit_code, + exit_reason, + oom_killed, + error_message, + finished_at, + started_at, + cpu_limit_cores, }) } @@ -1662,11 +1775,14 @@ impl ContainerDeployer for DockerRuntime { container_id: container_info.container_id, container_name: container_info.container_name, cpu_percent, + cpu_limit_cores: container_info.cpu_limit_cores, memory_bytes, memory_limit_bytes, memory_percent, network_rx_bytes, network_tx_bytes, + restart_count: container_info.restart_count, + started_at: container_info.started_at, timestamp: chrono::Utc::now(), }) } diff --git a/crates/temps-deployer/src/lib.rs b/crates/temps-deployer/src/lib.rs index 9ed8d77eb..8e1e92178 100644 --- a/crates/temps-deployer/src/lib.rs +++ b/crates/temps-deployer/src/lib.rs @@ -266,6 +266,57 @@ pub struct ContainerInfo { /// Docker labels set on the container (e.g., `sh.temps.managed`, `sh.temps.project_id`). #[serde(default)] pub labels: HashMap, + /// Process exit code reported by Docker. None while the container is still running. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub exit_code: Option, + /// Human-readable reason the container exited (e.g. "OOMKilled", + /// "Signal SIGKILL (9)", "Exit code 137"). None while still running. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub exit_reason: Option, + /// True when Docker's OOM killer terminated the container. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub oom_killed: Option, + /// Error string captured from Docker's container state on exit. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error_message: Option, + /// When the container exited (Docker's FinishedAt). None while still running. + #[schema(value_type = Option, example = "2025-10-12T12:16:47.609192Z")] + #[serde(skip_serializing_if = "Option::is_none", default)] + pub finished_at: Option, + /// When the container's main process most recently started (Docker's + /// StartedAt). On a restarted container this is *after* `created_at`, so + /// uptime should be derived from this rather than `created_at`. None for + /// containers that have never started. + #[schema(value_type = Option, example = "2025-10-12T12:15:50.000000Z")] + #[serde(skip_serializing_if = "Option::is_none", default)] + pub started_at: Option, + /// CPU limit applied to the container, in whole cores (e.g. `1.0` = + /// 1 vCPU, `0.5` = half a vCPU). None if no limit is set. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub cpu_limit_cores: Option, +} + +impl Default for ContainerInfo { + fn default() -> Self { + Self { + container_id: String::new(), + container_name: String::new(), + image_name: String::new(), + status: ContainerStatus::Created, + created_at: chrono::Utc::now(), + ports: Vec::new(), + environment_vars: HashMap::new(), + restart_count: None, + labels: HashMap::new(), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, + } + } } /// Container performance statistics (CPU, memory, network) @@ -275,6 +326,10 @@ pub struct ContainerStats { pub container_name: String, /// CPU usage percentage (0-100) pub cpu_percent: f64, + /// CPU limit applied to the container, in whole cores (e.g. `1.0`). + /// None if no limit is set. Lets the UI render "0.5 / 1.0 cores". + #[serde(skip_serializing_if = "Option::is_none", default)] + pub cpu_limit_cores: Option, /// Memory usage in bytes pub memory_bytes: u64, /// Memory limit in bytes (if set) @@ -285,10 +340,38 @@ pub struct ContainerStats { pub network_rx_bytes: u64, /// Network bytes transmitted pub network_tx_bytes: u64, + /// Container restart count from Docker. Lets the UI render + /// "Restarted 3×" so a restart loop is visible at a glance. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub restart_count: Option, + /// When the container's main process most recently started. Drives the + /// uptime label in the header — distinct from `created_at` because the + /// container may have been restarted in place after a crash. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub started_at: Option, /// Timestamp of metrics collection pub timestamp: UtcDateTime, } +impl Default for ContainerStats { + fn default() -> Self { + Self { + container_id: String::new(), + container_name: String::new(), + cpu_percent: 0.0, + cpu_limit_cores: None, + memory_bytes: 0, + memory_limit_bytes: None, + memory_percent: None, + network_rx_bytes: 0, + network_tx_bytes: 0, + restart_count: None, + started_at: None, + timestamp: chrono::Utc::now(), + } + } +} + /// Configuration for stopping containers #[derive(Debug, Clone)] pub struct ContainerStopSpec { @@ -706,6 +789,7 @@ mod tests { environment_vars: env_vars, restart_count: Some(0), labels: HashMap::new(), + ..Default::default() }; assert_eq!(info.container_id, "abc123"); diff --git a/crates/temps-deployments/src/handlers/deployments.rs b/crates/temps-deployments/src/handlers/deployments.rs index ce436874a..bf89b2423 100644 --- a/crates/temps-deployments/src/handlers/deployments.rs +++ b/crates/temps-deployments/src/handlers/deployments.rs @@ -1426,6 +1426,48 @@ pub async fn get_container_detail( .get_container_restart_count(&container.container_id) .await; + // Resolve configured resource limits the same way the workflow does: + // env override first, then project default. This is what was actually + // applied to the container at deploy time, modulo Docker honoring it. + let resource_limits: Option = { + let env = temps_entities::environments::Entity::find_by_id(environment_id) + .one(state.db.as_ref()) + .await + .ok() + .flatten(); + let proj = temps_entities::projects::Entity::find_by_id(project_id) + .one(state.db.as_ref()) + .await + .ok() + .flatten(); + let env_cfg = env.as_ref().and_then(|e| e.deployment_config.as_ref()); + let proj_cfg = proj.as_ref().and_then(|p| p.deployment_config.as_ref()); + let resolve = |g: fn( + &temps_entities::deployment_config::DeploymentConfig, + ) -> Option| + -> Option { + env_cfg.and_then(g).or_else(|| proj_cfg.and_then(g)) + }; + let cpu_request = resolve(|c| c.cpu_request); + let cpu_limit = resolve(|c| c.cpu_limit); + let memory_request = resolve(|c| c.memory_request); + let memory_limit = resolve(|c| c.memory_limit); + if cpu_request.is_some() + || cpu_limit.is_some() + || memory_request.is_some() + || memory_limit.is_some() + { + Some(crate::handlers::types::ResourceLimitsResponse { + cpu_request, + cpu_limit, + memory_request, + memory_limit, + }) + } else { + None + } + }; + // Resolve per-service URL only for ports marked as public in preset_config let service_url = if let Some(ref svc_name) = container.service_name { // Check if this service has a public port @@ -1495,9 +1537,16 @@ pub async fn get_container_detail( host_port: container.host_port, environment_variables: env_vars, restart_count, - resource_limits: None, + resource_limits, service_name: container.service_name, service_url, + exit_code: container.exit_code, + exit_reason: container.exit_reason, + oom_killed: container.oom_killed, + error_message: container.error_message, + finished_at: container.finished_at.map(|dt| dt.to_rfc3339()), + started_at: container.started_at.map(|dt| dt.to_rfc3339()), + cpu_limit_cores: container.cpu_limit_cores, }; Ok(Json(response).into_response()) @@ -1780,11 +1829,14 @@ pub async fn stream_container_metrics( "container_id": stats.container_id, "container_name": stats.container_name, "cpu_percent": stats.cpu_percent, + "cpu_limit_cores": stats.cpu_limit_cores, "memory_bytes": stats.memory_bytes, "memory_limit_bytes": stats.memory_limit_bytes, "memory_percent": stats.memory_percent, "network_rx_bytes": stats.network_rx_bytes, "network_tx_bytes": stats.network_tx_bytes, + "restart_count": stats.restart_count, + "started_at": stats.started_at.map(|t| t.to_rfc3339()), "timestamp": stats.timestamp.to_rfc3339(), }); diff --git a/crates/temps-deployments/src/handlers/nodes.rs b/crates/temps-deployments/src/handlers/nodes.rs index d806198aa..1ea4ad6f5 100644 --- a/crates/temps-deployments/src/handlers/nodes.rs +++ b/crates/temps-deployments/src/handlers/nodes.rs @@ -2323,6 +2323,13 @@ mod tests { ready_at: Some(chrono::Utc::now()), deleted_at: None, node_id: Some(1), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }; let c2 = deployment_containers::Model { id: 2, @@ -2339,6 +2346,13 @@ mod tests { ready_at: Some(chrono::Utc::now()), deleted_at: None, node_id: Some(1), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }; let mut c2_updated = c2.clone(); c2_updated.status = Some("removed".to_string()); @@ -2444,6 +2458,13 @@ mod tests { ready_at: Some(chrono::Utc::now()), deleted_at: None, node_id: Some(1), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }; let mut c1_updated = c1.clone(); c1_updated.status = Some("removed".to_string()); diff --git a/crates/temps-deployments/src/handlers/types.rs b/crates/temps-deployments/src/handlers/types.rs index 57e70f459..c8fac62c2 100644 --- a/crates/temps-deployments/src/handlers/types.rs +++ b/crates/temps-deployments/src/handlers/types.rs @@ -594,6 +594,36 @@ pub struct ContainerInfoResponse { /// Per-service URL for compose deployments (e.g. "https://web-myapp.localho.st") #[serde(skip_serializing_if = "Option::is_none")] pub service_url: Option, + /// Process exit code reported by Docker. None while still running. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable reason the container exited (e.g. "OOMKilled", + /// "Killed by SIGKILL (exit code 137)", "Exit code 1"). None while running. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_reason: Option, + /// True when Docker's OOM killer terminated the container. + #[serde(skip_serializing_if = "Option::is_none")] + pub oom_killed: Option, + /// Free-form error string from Docker's container state on exit. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// When the container exited (Docker's FinishedAt). None while running. + #[schema(example = "2025-10-12T12:16:47.609192Z")] + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + /// When the container's main process most recently started. The UI uses + /// this for the uptime label so the count resets when a container is + /// restarted in place. None for containers that never started. + #[schema(example = "2025-10-12T12:15:50.000000Z")] + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// Container restart count from Docker. The UI shows a chip when this is + /// > 0 so a crash loop is visible without opening detail. + #[serde(skip_serializing_if = "Option::is_none")] + pub restart_count: Option, + /// CPU limit in whole cores (e.g. 1.0). None when no limit is configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_limit_cores: Option, } impl ContainerInfoResponse { @@ -612,6 +642,14 @@ impl ContainerInfoResponse { node_name, service_name, service_url, + exit_code: info.exit_code, + exit_reason: info.exit_reason, + oom_killed: info.oom_killed, + error_message: info.error_message, + finished_at: info.finished_at.map(|d| d.to_rfc3339()), + started_at: info.started_at.map(|d| d.to_rfc3339()), + restart_count: info.restart_count, + cpu_limit_cores: info.cpu_limit_cores, } } } @@ -662,6 +700,29 @@ pub struct ContainerDetailResponse { /// Per-service URL for compose deployments #[serde(skip_serializing_if = "Option::is_none")] pub service_url: Option, + /// Process exit code reported by Docker. None while still running. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Human-readable reason the container exited. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_reason: Option, + /// True when Docker's OOM killer terminated the container. + #[serde(skip_serializing_if = "Option::is_none")] + pub oom_killed: Option, + /// Free-form error string from Docker's container state on exit. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// When the container exited (Docker's FinishedAt). None while running. + #[schema(example = "2025-10-12T12:16:47.609192Z")] + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + /// When the container's main process most recently started. + #[schema(example = "2025-10-12T12:15:50.000000Z")] + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// CPU limit in whole cores (e.g. 1.0). None when no limit is configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu_limit_cores: Option, } /// Environment variable with masked sensitive values diff --git a/crates/temps-deployments/src/jobs/deploy_image.rs b/crates/temps-deployments/src/jobs/deploy_image.rs index 2f251776f..cdc4d8042 100644 --- a/crates/temps-deployments/src/jobs/deploy_image.rs +++ b/crates/temps-deployments/src/jobs/deploy_image.rs @@ -108,15 +108,72 @@ pub struct ResourceUsage { impl Default for ResourceUsage { fn default() -> Self { + // Microcore convention: 1_000_000u = 1 core, 100_000u = 0.1 core. Self { - cpu_limit: Some("1000m".to_string()), + cpu_limit: Some("1000000u".to_string()), memory_limit: Some("512Mi".to_string()), - cpu_request: Some("100m".to_string()), + cpu_request: Some("100000u".to_string()), memory_request: Some("128Mi".to_string()), } } } +/// Parse a CPU quantity into whole cores. Accepts: +/// - microcores via `u` suffix: "2000000u" → 2.0 (1_000_000u = 1 core) +/// - millicores via `m` suffix: "1000m" → 1.0 (1_000m = 1 core) +/// - bare cores: "2" → 2.0 +/// +/// Returns None on unrecognized input so the caller can fall back to "no limit". +pub(crate) fn parse_cpu_cores(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + if let Some(micro) = trimmed.strip_suffix('u') { + return micro.parse::().ok().map(|v| v / 1_000_000.0); + } + if let Some(milli) = trimmed.strip_suffix('m') { + return milli.parse::().ok().map(|v| v / 1000.0); + } + trimmed.parse::().ok() +} + +/// Parse a Kubernetes-style memory quantity into megabytes (binary units, so +/// "1Gi" → 1024 MB). Accepts Ki/Mi/Gi/Ti and the decimal K/M/G/T variants; +/// bare numbers are interpreted as bytes and rounded up to the nearest MB. +pub(crate) fn parse_memory_mb(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + let (num, factor_to_bytes): (&str, f64) = if let Some(v) = trimmed.strip_suffix("Ki") { + (v, 1024.0) + } else if let Some(v) = trimmed.strip_suffix("Mi") { + (v, 1024.0 * 1024.0) + } else if let Some(v) = trimmed.strip_suffix("Gi") { + (v, 1024.0 * 1024.0 * 1024.0) + } else if let Some(v) = trimmed.strip_suffix("Ti") { + (v, 1024.0 * 1024.0 * 1024.0 * 1024.0) + } else if let Some(v) = trimmed.strip_suffix('K') { + (v, 1000.0) + } else if let Some(v) = trimmed.strip_suffix('M') { + (v, 1_000_000.0) + } else if let Some(v) = trimmed.strip_suffix('G') { + (v, 1_000_000_000.0) + } else if let Some(v) = trimmed.strip_suffix('T') { + (v, 1_000_000_000_000.0) + } else { + (trimmed, 1.0) + }; + let value = num.parse::().ok()?; + let mb = (value * factor_to_bytes) / (1024.0 * 1024.0); + if mb.is_finite() && mb >= 0.0 { + Some(mb.ceil() as u64) + } else { + None + } +} + /// Configuration for deployment job execution /// This is built from the entity's DeploymentConfig + runtime values #[derive(Debug, Clone)] @@ -978,19 +1035,24 @@ impl DeployImageJob { protocol: Protocol::Tcp, }]; + // Convert k8s-style strings ("1000m", "512Mi", "2", "1Gi") into the + // numeric ResourceLimits the deployer feeds to bollard. The previous + // implementation called `parse::()` directly, which silently + // returned None for any value containing a unit suffix — so even the + // builder defaults ("1000m" / "512Mi") never made it to the container. let resource_limits = ResourceLimits { cpu_limit: self .config .resources .cpu_limit .as_ref() - .and_then(|s| s.parse::().ok()), + .and_then(|s| parse_cpu_cores(s)), memory_limit_mb: self .config .resources .memory_limit .as_ref() - .and_then(|s| s.trim_end_matches("Mi").parse::().ok()), + .and_then(|s| parse_memory_mb(s)), disk_limit_mb: None, }; @@ -1940,6 +2002,38 @@ mod tests { use super::*; use async_trait::async_trait; + #[test] + fn parse_cpu_cores_handles_millicores_and_whole_cores() { + assert_eq!(parse_cpu_cores("1000m"), Some(1.0)); + assert_eq!(parse_cpu_cores("500m"), Some(0.5)); + assert_eq!(parse_cpu_cores("2"), Some(2.0)); + assert_eq!(parse_cpu_cores("0.25"), Some(0.25)); + assert_eq!(parse_cpu_cores(" 1500m "), Some(1.5)); + assert_eq!(parse_cpu_cores(""), None); + assert_eq!(parse_cpu_cores("garbage"), None); + } + + #[test] + fn parse_cpu_cores_handles_microcores() { + // 1_000_000u = 1 core (the storage convention used by temps DB). + assert_eq!(parse_cpu_cores("1000000u"), Some(1.0)); + assert_eq!(parse_cpu_cores("500000u"), Some(0.5)); + assert_eq!(parse_cpu_cores("2000000u"), Some(2.0)); + assert_eq!(parse_cpu_cores("100000u"), Some(0.1)); + assert_eq!(parse_cpu_cores(" 2000000u "), Some(2.0)); + } + + #[test] + fn parse_memory_mb_handles_binary_and_decimal_suffixes() { + assert_eq!(parse_memory_mb("512Mi"), Some(512)); + assert_eq!(parse_memory_mb("1Gi"), Some(1024)); + assert_eq!(parse_memory_mb("2048Ki"), Some(2)); + assert_eq!(parse_memory_mb("1G"), Some(954)); // 1e9 / (1024*1024) ≈ 953.67 → ceil + assert_eq!(parse_memory_mb("128"), Some(1)); // 128 bytes → ceil to 1 MB + assert_eq!(parse_memory_mb(""), None); + assert_eq!(parse_memory_mb("garbage"), None); + } + use temps_deployer::{ ContainerDeployer, ContainerInfo, ContainerStats, ContainerStatus as DeployerContainerStatus, DeployRequest, DeployResult, DeployerError, @@ -2030,6 +2124,7 @@ mod tests { environment_vars: HashMap::new(), restart_count: Some(0), labels: HashMap::new(), + ..Default::default() }) } @@ -2047,6 +2142,7 @@ mod tests { network_rx_bytes: 2048000, network_tx_bytes: 1024000, timestamp: chrono::Utc::now(), + ..Default::default() }) } diff --git a/crates/temps-deployments/src/jobs/pipeline_validation.rs b/crates/temps-deployments/src/jobs/pipeline_validation.rs index 7c5bc9867..843704cef 100644 --- a/crates/temps-deployments/src/jobs/pipeline_validation.rs +++ b/crates/temps-deployments/src/jobs/pipeline_validation.rs @@ -138,6 +138,7 @@ impl ContainerDeployer for MockContainerDeployer { environment_vars: HashMap::new(), restart_count: Some(0), labels: HashMap::new(), + ..Default::default() }) } @@ -170,6 +171,7 @@ impl ContainerDeployer for MockContainerDeployer { network_rx_bytes: 1024000, network_tx_bytes: 512000, timestamp: chrono::Utc::now(), + ..Default::default() }) } } diff --git a/crates/temps-deployments/src/services/node_service.rs b/crates/temps-deployments/src/services/node_service.rs index a64fc9a4f..b63c7eb11 100644 --- a/crates/temps-deployments/src/services/node_service.rs +++ b/crates/temps-deployments/src/services/node_service.rs @@ -712,6 +712,13 @@ mod tests { ready_at: Some(chrono::Utc::now()), deleted_at: None, node_id: Some(node_id), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, } } diff --git a/crates/temps-deployments/src/services/services.rs b/crates/temps-deployments/src/services/services.rs index a56776239..38a9f4874 100644 --- a/crates/temps-deployments/src/services/services.rs +++ b/crates/temps-deployments/src/services/services.rs @@ -3600,6 +3600,7 @@ mod tests { status: temps_deployer::ContainerStatus::Running, restart_count: Some(0), labels: std::collections::HashMap::new(), + ..Default::default() }) }); deployer.expect_list_containers().returning(|| Ok(vec![])); diff --git a/crates/temps-deployments/src/services/workflow_execution_service.rs b/crates/temps-deployments/src/services/workflow_execution_service.rs index 248ee31e8..0609762bb 100644 --- a/crates/temps-deployments/src/services/workflow_execution_service.rs +++ b/crates/temps-deployments/src/services/workflow_execution_service.rs @@ -20,7 +20,8 @@ use tracing::{debug, error, info, warn}; use crate::jobs::{ AgentSyncService, BuildImageJobBuilder, ConfigureAgentsJobBuilder, ConfigureCronsJobBuilder, CronConfigService, DeployImageJobBuilder, DeployStaticBundleJob, DeployStaticJob, - DeploymentTarget, DownloadRepoBuilder, PullExternalImageJob, VerifyLocalImageJob, + DeploymentTarget, DownloadRepoBuilder, PullExternalImageJob, ResourceUsage, + VerifyLocalImageJob, }; use crate::services::DeploymentJobTracker; use temps_screenshots::ScreenshotService; @@ -681,6 +682,36 @@ impl WorkflowExecutionService { .and_then(|c| c.target_labels.clone()) }); + // Resolve CPU/memory limits + requests from environment first, + // then project. Each field is resolved independently so an + // env override can supply only memory while inheriting the + // project's CPU. When neither side configures a value we leave + // it unset and the deployer applies no limit at the Docker + // layer (rather than the previous hardcoded 1000m/512Mi). + let env_cfg = environment.deployment_config.as_ref(); + let proj_cfg = project.deployment_config.as_ref(); + let resolve_i32 = |getter: fn( + &temps_entities::deployment_config::DeploymentConfig, + ) -> Option| + -> Option { + env_cfg + .and_then(getter) + .or_else(|| proj_cfg.and_then(getter)) + }; + // CPU values are stored as microcores in the DB (1_000_000 = 1 core); + // memory is stored as MB. Format with explicit suffixes the deployer + // understands: `u` for microcores, `Mi` for mebibytes. + let cpu_limit_micro = resolve_i32(|c| c.cpu_limit); + let memory_limit_mb = resolve_i32(|c| c.memory_limit); + let cpu_request_micro = resolve_i32(|c| c.cpu_request); + let memory_request_mb = resolve_i32(|c| c.memory_request); + let resources = ResourceUsage { + cpu_limit: cpu_limit_micro.map(|u| format!("{}u", u)), + memory_limit: memory_limit_mb.map(|mb| format!("{}Mi", mb)), + cpu_request: cpu_request_micro.map(|u| format!("{}u", u)), + memory_request: memory_request_mb.map(|mb| format!("{}Mi", mb)), + }; + let mut builder = DeployImageJobBuilder::new() .job_id(db_job.job_id.clone()) .build_job_id(build_job_id) @@ -695,6 +726,7 @@ impl WorkflowExecutionService { .environment_variables(env_variables) .remote_environment_variables(remote_env_variables) .secrets(secrets) + .resources(resources) .log_id(db_job.log_id.clone()) .log_service(self.log_service.clone()); @@ -2224,6 +2256,7 @@ mod tests { status: temps_deployer::ContainerStatus::Running, restart_count: Some(0), labels: HashMap::new(), + ..Default::default() }) } @@ -2241,6 +2274,7 @@ mod tests { network_rx_bytes: 1024000, network_tx_bytes: 512000, timestamp: chrono::Utc::now(), + ..Default::default() }) } diff --git a/crates/temps-deployments/tests/multinode_integration_test.rs b/crates/temps-deployments/tests/multinode_integration_test.rs index 46c670bf8..4f13766bd 100644 --- a/crates/temps-deployments/tests/multinode_integration_test.rs +++ b/crates/temps-deployments/tests/multinode_integration_test.rs @@ -64,6 +64,13 @@ fn make_container( ready_at: Some(chrono::Utc::now()), deleted_at: None, node_id: Some(node_id), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, } } diff --git a/crates/temps-entities/src/deployment_containers.rs b/crates/temps-entities/src/deployment_containers.rs index e9cd96c9e..b75ffd45a 100644 --- a/crates/temps-entities/src/deployment_containers.rs +++ b/crates/temps-entities/src/deployment_containers.rs @@ -4,7 +4,9 @@ use sea_orm::{ActiveValue::Set, ConnectionTrait, DbErr}; use serde::{Deserialize, Serialize}; use temps_core::DBDateTime; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +// Eq dropped because cpu_limit_cores is f64 (NaN-safe equality isn't a thing). +// PartialEq is enough for the few diff checks the monitoring loop does. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "deployment_containers")] pub struct Model { #[sea_orm(primary_key)] @@ -25,6 +27,26 @@ pub struct Model { pub deleted_at: Option, /// Node this container runs on. NULL = local node (single-node mode). pub node_id: Option, + /// Process exit code from Docker (NULL if still running or never inspected post-exit). + pub exit_code: Option, + /// Human-readable reason the container exited, e.g. "OOMKilled", + /// "Signal SIGKILL (9)", "Exit code 137". NULL if still running. + #[sea_orm(column_type = "String(StringLen::N(255))", nullable)] + pub exit_reason: Option, + /// True when Docker reported the container was killed by the OOM killer. + pub oom_killed: Option, + /// Free-form error string captured from Docker's container state on exit. + #[sea_orm(column_type = "Text", nullable)] + pub error_message: Option, + /// When the container exited (FinishedAt from Docker inspect). + pub finished_at: Option, + /// When the container's main process most recently started (Docker's + /// StartedAt). Distinct from `created_at` because the container may + /// restart in place after a crash. + pub started_at: Option, + /// CPU limit applied to the container, in whole cores (e.g. 1.0 = 1 vCPU). + /// NULL if no limit is configured. + pub cpu_limit_cores: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/temps-entities/src/projects.rs b/crates/temps-entities/src/projects.rs index 1912f4a52..92cc7abe4 100644 --- a/crates/temps-entities/src/projects.rs +++ b/crates/temps-entities/src/projects.rs @@ -46,6 +46,14 @@ pub struct Model { /// Defaults to 'git' for backward compatibility #[sea_orm(default_value = "git")] pub source_type: SourceType, + /// GitLab webhook ID returned by POST /projects/:id/hooks when we auto-install + /// the webhook on repo connect. NULL when not connected to a GitLab repository. + pub gitlab_webhook_id: Option, + /// Encrypted signing token we generated and sent to GitLab as `signing_token` + /// when creating the webhook. Used to validate incoming webhook payloads. + /// Never serialized in API responses. + #[serde(skip_serializing)] + pub gitlab_webhook_signing_token: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/temps-git/Cargo.toml b/crates/temps-git/Cargo.toml index 34505c570..3315069a0 100644 --- a/crates/temps-git/Cargo.toml +++ b/crates/temps-git/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true repository.workspace = true homepage.workspace = true +[dev-dependencies] +mockito = "1.5" + [dependencies] temps-auth = { path = "../temps-auth" } temps-core = { path = "../temps-core" } @@ -48,3 +51,4 @@ hyper = { workspace = true } flate2 = "1.0" tar = "0.4.45" tempfile = "3.10" +rand = { workspace = true } diff --git a/crates/temps-git/src/handlers/gitlab.rs b/crates/temps-git/src/handlers/gitlab.rs index a0da6a9bc..c9991080e 100644 --- a/crates/temps-git/src/handlers/gitlab.rs +++ b/crates/temps-git/src/handlers/gitlab.rs @@ -1,13 +1,17 @@ use axum::{ extract::{Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::Redirect, + routing::post, Router, }; +use bytes::Bytes; +use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; use temps_core::problemdetails::{new as problem_new, Problem}; -use tracing::info; +use tracing::{error, info, warn}; +use utoipa::ToSchema; use super::types::GitAppState as AppState; @@ -21,6 +25,242 @@ pub fn configure_routes() -> Router> { "/webhook/git/gitlab/auth", axum::routing::get(gitlab_oauth_callback), ) + .route("/webhook/git/gitlab/events", post(gitlab_webhook_events)) +} + +// ── GitLab webhook event receiver ───────────────────────────────────────── + +#[derive(Serialize, ToSchema)] +pub struct GitLabWebhookResponse { + message: String, +} + +/// Receive GitLab push/tag events and trigger Temps deployment pipelines. +/// +/// GitLab signs each delivery with HMAC-SHA256 of the raw body using the +/// `signing_token` configured on the webhook. The signature is sent in +/// `webhook-signature: v1,{base64}`. The legacy plaintext `X-Gitlab-Token` +/// header is intentionally NOT supported — it's deprecated and not +/// recommended by GitLab. +/// +/// For each Temps project that has a matching `repo_owner/repo_name` *and* +/// whose stored signing token validates the HMAC, we queue a `GitPushEventJob` +/// via the same code path as the GitHub handler. +async fn gitlab_webhook_events( + headers: HeaderMap, + State(state): State>, + body: Bytes, +) -> Result, Problem> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use temps_entities::projects; + + let event_type = headers + .get("X-Gitlab-Event") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + // GitLab signs each delivery using Standard-Webhooks-style HMAC-SHA256: + // the signed message is "{webhook-id}.{webhook-timestamp}.{body}" and the + // signature lands in `webhook-signature: v1,{base64}`. The legacy + // plaintext `X-Gitlab-Token` is not supported. + // + // Axum lowercases all header names, so the lowercase forms are the + // canonical lookups even though GitLab capitalises them as + // `Webhook-Signature` / `Webhook-Id` / `Webhook-Timestamp`. + let webhook_signature_header = headers + .get("webhook-signature") + .and_then(|v| v.to_str().ok()); + let webhook_id_header = headers + .get("webhook-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let webhook_timestamp_header = headers + .get("webhook-timestamp") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + info!("Received GitLab webhook event: {}", event_type); + + // Parse the payload to get project owner / name and the pushed ref. + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + error!("Failed to parse GitLab webhook payload: {}", e); + return Err(problem_new(StatusCode::BAD_REQUEST) + .with_title("Invalid Payload") + .with_detail("GitLab webhook body is not valid JSON")); + } + }; + + // GitLab puts the repo path in `project.path_with_namespace` + // (e.g. "group/my-repo") and the namespace in `project.namespace`. + // We decompose owner / name from the full path. + let path_with_namespace = payload + .get("project") + .and_then(|p| p.get("path_with_namespace")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let (repo_owner, repo_name) = match path_with_namespace.rsplit_once('/') { + Some((owner, name)) => (owner, name), + None => { + warn!("GitLab webhook missing project.path_with_namespace, skipping"); + return Ok(axum::Json(GitLabWebhookResponse { + message: "No matching project found".to_string(), + })); + } + }; + + let git_ref = payload.get("ref").and_then(|v| v.as_str()).unwrap_or(""); + + let branch = if git_ref.starts_with("refs/heads/") { + Some(git_ref.replace("refs/heads/", "")) + } else { + None + }; + + let tag = if git_ref.starts_with("refs/tags/") { + Some(git_ref.replace("refs/tags/", "")) + } else { + None + }; + + let commit = payload + .get("checkout_sha") + .and_then(|v| v.as_str()) + .or_else(|| { + payload + .get("commits") + .and_then(|cs| cs.as_array()) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("id")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("") + .to_string(); + + // Find all Temps projects matching this repo. + let matching_projects = match projects::Entity::find() + .filter(projects::Column::RepoOwner.eq(repo_owner)) + .filter(projects::Column::RepoName.eq(repo_name)) + .all(state.git_provider_manager.db()) + .await + { + Ok(ps) => ps, + Err(e) => { + error!("DB error looking up projects for GitLab push: {}", e); + return Ok(axum::Json(GitLabWebhookResponse { + message: "processed".to_string(), + })); + } + }; + + if matching_projects.is_empty() { + warn!( + "No Temps projects found for GitLab repo {}/{}, ignoring event", + repo_owner, repo_name + ); + return Ok(axum::Json(GitLabWebhookResponse { + message: "No matching project found".to_string(), + })); + } + + // Validate the signing token against each project. We need at least one + // project to authenticate the request before dispatching the push event. + // `handle_push_event` internally re-queries all matching projects, so we + // call it only once (not per project) to avoid duplicate queue entries. + let mut any_valid = false; + + for project in &matching_projects { + // Verify the signing token for this project. + let token_valid = match project.gitlab_webhook_signing_token.as_deref() { + Some(encrypted) => { + match state.git_provider_manager.decrypt_token(encrypted).await { + Ok(plaintext) => { + if let Some(sig) = webhook_signature_header { + // Standard-Webhooks HMAC: signs + // "{webhook-id}.{timestamp}.{body}" with the + // 32-byte key derived from the whsec_ token. + crate::services::gitlab_webhook::verify_gitlab_signature( + &body, + sig, + webhook_id_header, + webhook_timestamp_header, + &plaintext, + ) + } else { + // No webhook-signature header — reject. Legacy + // X-Gitlab-Token is intentionally not supported. + false + } + } + Err(e) => { + warn!( + "Failed to decrypt signing token for project {}: {}", + project.id, e + ); + false + } + } + } + None => { + // Project has no stored token — accept without signature check + // (public repositories or projects that pre-date this feature). + true + } + }; + + if token_valid { + any_valid = true; + break; + } else { + warn!( + "GitLab webhook signature mismatch for project {}, skipping", + project.id + ); + } + } + + if !any_valid { + warn!( + "No matching GitLab project with valid signature for {}/{}, ignoring", + repo_owner, repo_name + ); + return Ok(axum::Json(GitLabWebhookResponse { + message: "Signature validation failed".to_string(), + })); + } + + // Dispatch through the existing push-event machinery (handles all projects + // for this repo in a single call). + let triggered = if let Err(e) = state + .git_provider_manager + .handle_push_event( + repo_owner.to_string(), + repo_name.to_string(), + branch.clone(), + tag.clone(), + commit.clone(), + ) + .await + { + error!( + "Failed to handle GitLab push event for {}/{}: {:?}", + repo_owner, repo_name, e + ); + 0 + } else { + matching_projects.len() + }; + + info!( + "GitLab webhook event processed: {} project(s) queued for {}/{}", + triggered, repo_owner, repo_name + ); + + Ok(axum::Json(GitLabWebhookResponse { + message: format!("Processed {} project(s)", triggered), + })) } /// Handle GitLab OAuth callback @@ -94,3 +334,69 @@ async fn gitlab_oauth_callback( Ok(Redirect::to(&redirect_url)) } + +#[cfg(test)] +mod tests { + //! Smoke-tests for the helpers from the handler perspective. Detailed + //! crypto edge-cases live in `services::gitlab_webhook::tests`. + + /// Build a valid `v1,{base64}` Standard-Webhooks signature for a given + /// payload + headers + signing token. + fn make_sig(payload: &[u8], webhook_id: &str, ts: &str, signing_token: &str) -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = STANDARD + .decode(signing_token.strip_prefix("whsec_").expect("whsec_ prefix")) + .expect("base64 key"); + let mut mac = Hmac::::new_from_slice(&key).unwrap(); + let mut msg: Vec = Vec::new(); + msg.extend_from_slice(webhook_id.as_bytes()); + msg.push(b'.'); + msg.extend_from_slice(ts.as_bytes()); + msg.push(b'.'); + msg.extend_from_slice(payload); + mac.update(&msg); + format!("v1,{}", STANDARD.encode(mac.finalize().into_bytes())) + } + + fn test_token() -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + format!("whsec_{}", STANDARD.encode([0u8; 32])) + } + + #[test] + fn test_verify_hmac_signature_via_service_helper() { + use crate::services::gitlab_webhook::verify_gitlab_signature; + + let token = test_token(); + let payload = b"push event body does not matter"; + let header = make_sig(payload, "wh_a", "1700000000", &token); + + assert!( + verify_gitlab_signature(payload, &header, "wh_a", "1700000000", &token), + "correct HMAC header must pass" + ); + assert!( + !verify_gitlab_signature(b"different body", &header, "wh_a", "1700000000", &token), + "HMAC computed over different body must fail" + ); + assert!( + !verify_gitlab_signature(payload, &header, "wh_a", "1700000099", &token), + "different timestamp must fail" + ); + } + + #[test] + fn test_generate_signing_token_via_service_helper() { + use crate::services::gitlab_webhook::generate_signing_token; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + let tok = generate_signing_token(); + assert!(tok.starts_with("whsec_")); + let decoded = STANDARD + .decode(tok.strip_prefix("whsec_").unwrap()) + .expect("valid base64"); + assert_eq!(decoded.len(), 32); + } +} diff --git a/crates/temps-git/src/services/git_provider_manager.rs b/crates/temps-git/src/services/git_provider_manager.rs index b66a39d1e..034e653b7 100644 --- a/crates/temps-git/src/services/git_provider_manager.rs +++ b/crates/temps-git/src/services/git_provider_manager.rs @@ -192,6 +192,19 @@ impl GitProviderManager { } } + /// Expose the underlying database connection for crate-internal use. + pub fn db(&self) -> &DatabaseConnection { + self.db.as_ref() + } + + /// Decrypt an encrypted token string (e.g., a GitLab webhook signing token). + /// Thin public wrapper around the private `decrypt_string` helper so that + /// webhook handlers inside the same crate can access it without going through + /// the full service layer. + pub async fn decrypt_token(&self, encrypted: &str) -> Result { + self.decrypt_string(encrypted).await + } + /// Get an access token from any active GitHub connection. /// Used for public repo API calls to avoid unauthenticated rate limits (60 → 5000 req/hr). /// Validates the token against GitHub's API before returning it. @@ -457,6 +470,101 @@ impl GitProviderManager { } } + /// Unconditionally refresh the OAuth/GitLab App access token for a + /// connection, bypassing the expires_at check. Use this after a clone or + /// download fails with an auth error: the stored token may have been + /// revoked or invalidated server-side even if our recorded expiry says + /// otherwise. Falls back to `validate_and_refresh_connection_token` for + /// auth methods that don't use refresh tokens (PAT, GitHub App). + pub async fn force_refresh_connection_token( + &self, + connection_id: i32, + ) -> Result { + use chrono::Utc; + use sea_orm::{ActiveModelTrait, Set}; + + let connection = self.get_connection(connection_id).await?; + let provider = self.get_provider(connection.provider_id).await?; + + let auth_config = self.decrypt_sensitive_data(&provider.auth_config).await?; + let auth_method = serde_json::from_value::(auth_config).map_err(|e| { + GitProviderManagerError::InvalidConfiguration(format!("Invalid auth config: {}", e)) + })?; + + match auth_method { + // OAuth / GitLab App: always exchange the refresh token for a new pair. + AuthMethod::OAuth { .. } | AuthMethod::GitLabApp { .. } => { + let refresh_encrypted = connection.refresh_token.as_ref().ok_or_else(|| { + GitProviderManagerError::InvalidConfiguration( + "Cannot force-refresh: no refresh token stored for connection".to_string(), + ) + })?; + let refresh_token = self.decrypt_string(refresh_encrypted).await?; + + let access_token_old = match connection.access_token.as_ref() { + Some(enc) => self.decrypt_string(enc).await?, + None => String::new(), + }; + + info!( + "Force-refreshing OAuth/GitLabApp token for connection {} after auth failure", + connection_id + ); + let provider_service = self.get_provider_service(provider.id).await?; + let (new_access_token, new_refresh_token) = provider_service + .validate_and_refresh_token(&access_token_old, Some(&refresh_token)) + .await?; + + let encrypted_access_token = self.encrypt_string(&new_access_token).await?; + let mut active_connection: git_provider_connections::ActiveModel = + connection.into(); + active_connection.access_token = Set(Some(encrypted_access_token)); + if let Some(new_refresh) = new_refresh_token { + let encrypted_refresh = self.encrypt_string(&new_refresh).await?; + active_connection.refresh_token = Set(Some(encrypted_refresh)); + } + active_connection.token_expires_at = + Set(Some(Utc::now() + chrono::Duration::hours(1))); + active_connection.updated_at = Set(Utc::now()); + active_connection.update(self.db.as_ref()).await?; + + Ok(new_access_token) + } + // GitHub App installation tokens regenerate via JWT, not refresh + // token. Delegate to the existing path which already handles that. + AuthMethod::GitHubApp { .. } => { + self.validate_and_refresh_connection_token(connection_id) + .await + } + // PATs can't be refreshed — surface the original token. Caller's + // retry will fail again, which is the correct signal. + AuthMethod::PersonalAccessToken { .. } => { + self.validate_and_refresh_connection_token(connection_id) + .await + } + _ => Err(GitProviderManagerError::InvalidConfiguration( + "Unsupported auth method for force-refresh".to_string(), + )), + } + } + + /// Heuristically detect whether an error message represents an + /// authentication/authorization failure that a token refresh might fix. + /// Used by clone/download retry paths since the libgit2 + reqwest error + /// surface doesn't expose HTTP status codes uniformly. + fn is_auth_failure(message: &str) -> bool { + let lower = message.to_lowercase(); + lower.contains("401") + || lower.contains("403") + || lower.contains("unauthorized") + || lower.contains("authentication failed") + || lower.contains("authentication required") + || lower.contains("invalid token") + || lower.contains("invalid_token") + || lower.contains("access token") + || lower.contains("token expired") + } + /// Validate and refresh connection token if needed /// Returns the valid access token pub async fn validate_and_refresh_connection_token( @@ -548,10 +656,16 @@ impl GitProviderManager { })?; let access_token = self.decrypt_string(access_token).await?; - // Check if expired (with 60 second buffer) + // Check if expired (with 5 minute buffer). The previous 60-second + // buffer was not enough headroom: a slow clone over flaky + // network can easily run past the wire-time check before the + // libgit2 credential callback even fires, leaving us with a + // freshly-validated-but-just-expired token. Five minutes makes + // that window much harder to hit while still letting us reuse + // tokens for nearly their entire lifetime. let should_refresh = if let Some(expires_at) = connection.token_expires_at { let now = Utc::now(); - let buffer = chrono::Duration::seconds(60); + let buffer = chrono::Duration::minutes(5); expires_at < now + buffer } else { false @@ -4435,21 +4549,99 @@ impl GitProviderManagerTrait for GitProviderManager { .await .map_err(|e| TraitError::DecryptionError(e.to_string()))?; - // Get repository info - let repo = provider_service + // Get repository info — also retry-on-auth, since the same stale + // token would fail here before we ever reach the clone. + let repo = match provider_service .get_repository(&access_token, repo_owner, repo_name) .await - .map_err(|e| TraitError::CloneError(format!("Failed to get repository: {}", e)))?; + { + Ok(r) => r, + Err(e) if Self::is_auth_failure(&e.to_string()) => { + tracing::warn!( + connection_id, + "get_repository hit auth failure ({}); force-refreshing token and retrying", + e + ); + let refreshed = self + .force_refresh_connection_token(connection_id) + .await + .map_err(|err| TraitError::DecryptionError(err.to_string()))?; + provider_service + .get_repository(&refreshed, repo_owner, repo_name) + .await + .map_err(|err| { + TraitError::CloneError(format!( + "Failed to get repository after token refresh: {}", + err + )) + })? + } + Err(e) => { + return Err(TraitError::CloneError(format!( + "Failed to get repository: {}", + e + ))) + } + }; - // Clone the repository - provider_service - .clone_repository( - &repo.clone_url, - target_dir.to_str().unwrap(), - Some(&access_token), - ) - .await - .map_err(|e| TraitError::CloneError(format!("Failed to clone: {}", e)))?; + // Clone the repository. On auth failure (libgit2 surfaces 401/403 as a + // generic credential error), force-refresh the OAuth token and retry + // once. Two motivations: + // 1. Time-of-check / time-of-use gap: the token we validated above + // may have expired between then and libgit2's HTTPS auth attempt. + // 2. Server-side revocation: GitLab can invalidate a token before + // its recorded expiry. + // libgit2's credential callback only fires once, so we have to retry + // the whole operation rather than refreshing inside the callback. + let target_str = target_dir.to_str().ok_or_else(|| { + TraitError::CloneError("target directory contains invalid UTF-8".to_string()) + })?; + let clone_result = provider_service + .clone_repository(&repo.clone_url, target_str, Some(&access_token)) + .await; + + if let Err(e) = clone_result { + if Self::is_auth_failure(&e.to_string()) { + tracing::warn!( + connection_id, + "clone_repository hit auth failure ({}); force-refreshing token and retrying once", + e + ); + + // Wipe the partially-cloned state, otherwise the retry trips + // the "directory not empty" check or fails with a corrupted + // git tree. + if target_dir.exists() { + if let Err(rm) = std::fs::remove_dir_all(target_dir) { + return Err(TraitError::CloneError(format!( + "Auth retry: failed to clean partial clone at {}: {}", + target_dir.display(), + rm + ))); + } + std::fs::create_dir_all(target_dir).map_err(|err| { + TraitError::CloneError(format!( + "Auth retry: failed to recreate target directory: {}", + err + )) + })?; + } + + let refreshed = self + .force_refresh_connection_token(connection_id) + .await + .map_err(|err| TraitError::DecryptionError(err.to_string()))?; + + provider_service + .clone_repository(&repo.clone_url, target_str, Some(&refreshed)) + .await + .map_err(|err| { + TraitError::CloneError(format!("Failed to clone after refresh: {}", err)) + })?; + } else { + return Err(TraitError::CloneError(format!("Failed to clone: {}", e))); + } + } // Checkout specific ref if provided if let Some(ref_name) = branch_or_ref { @@ -4562,7 +4754,10 @@ impl GitProviderManagerTrait for GitProviderManager { .await .map_err(|e| TraitError::DecryptionError(e.to_string()))?; - provider_service + // Same retry-on-auth logic as clone_repository: the token we just + // validated may have expired or been revoked between the check and + // the HTTP request the provider issues to download the tarball. + match provider_service .download_archive( &access_token, repo_owner, @@ -4571,9 +4766,46 @@ impl GitProviderManagerTrait for GitProviderManager { archive_path, ) .await - .map_err(|e| TraitError::Other(format!("Failed to download archive: {}", e)))?; - - Ok(()) + { + Ok(()) => Ok(()), + Err(e) if Self::is_auth_failure(&e.to_string()) => { + tracing::warn!( + connection_id, + "download_archive hit auth failure ({}); force-refreshing token and retrying once", + e + ); + // The provider usually creates the archive file as it streams + // — if the auth failed mid-stream we'd be left with a partial + // file. Remove it so the retry starts clean. + if archive_path.exists() { + let _ = std::fs::remove_file(archive_path); + } + let refreshed = self + .force_refresh_connection_token(connection_id) + .await + .map_err(|err| TraitError::DecryptionError(err.to_string()))?; + provider_service + .download_archive( + &refreshed, + repo_owner, + repo_name, + branch_or_ref, + archive_path, + ) + .await + .map_err(|err| { + TraitError::Other(format!( + "Failed to download archive after token refresh: {}", + err + )) + })?; + Ok(()) + } + Err(e) => Err(TraitError::Other(format!( + "Failed to download archive: {}", + e + ))), + } } async fn push_files_and_create_pr( @@ -4650,6 +4882,38 @@ mod tests { use temps_database::test_utils::TestDatabase; use temps_entities::{git_provider_connections, git_providers}; + #[test] + fn is_auth_failure_recognizes_common_messages() { + // Things that should trigger a retry-with-fresh-token. + assert!(GitProviderManager::is_auth_failure( + "HTTP 401: Unauthorized" + )); + assert!(GitProviderManager::is_auth_failure("HTTP 403")); + assert!(GitProviderManager::is_auth_failure( + "remote: HTTP authentication required" + )); + assert!(GitProviderManager::is_auth_failure( + "authentication failed for 'https://gitlab.com/foo/bar.git'" + )); + assert!(GitProviderManager::is_auth_failure( + "GitLab error: invalid_token" + )); + assert!(GitProviderManager::is_auth_failure( + "the provided access token is invalid" + )); + assert!(GitProviderManager::is_auth_failure( + "HTTP 401: token expired" + )); + + // Things that should NOT trigger a retry — refreshing won't help. + assert!(!GitProviderManager::is_auth_failure("HTTP 404: not found")); + assert!(!GitProviderManager::is_auth_failure( + "connection refused (ECONNREFUSED)" + )); + assert!(!GitProviderManager::is_auth_failure("directory not empty")); + assert!(!GitProviderManager::is_auth_failure("disk full")); + } + // Mock implementations for tests struct MockJobQueue; diff --git a/crates/temps-git/src/services/gitlab_provider.rs b/crates/temps-git/src/services/gitlab_provider.rs index ef1f59e21..a226cb997 100644 --- a/crates/temps-git/src/services/gitlab_provider.rs +++ b/crates/temps-git/src/services/gitlab_provider.rs @@ -642,27 +642,6 @@ impl GitProviderService for GitLabProvider { owner: &str, repo: &str, ) -> Result, GitProviderError> { - let client = self.get_client(); - let headers = self.get_headers(access_token); - - let project_path = format!("{}/{}", owner, repo); - let encoded_path = urlencoding::encode(&project_path); - let url = format!( - "{}/api/v4/projects/{}/repository/branches", - self.base_url, encoded_path - ); - - let response = self - .send_with_retry(|| client.get(&url).headers(headers.clone())) - .await?; - - if !response.status().is_success() { - return Err(GitProviderError::ApiError(format!( - "Failed to list branches: {}", - response.status() - ))); - } - #[derive(Deserialize)] struct GitLabBranch { name: String, @@ -675,21 +654,59 @@ impl GitProviderService for GitLabProvider { id: String, } - let gitlab_branches: Vec = response - .json() - .await - .map_err(|e| GitProviderError::ApiError(e.to_string()))?; + let client = self.get_client(); + let headers = self.get_headers(access_token); - let branches = gitlab_branches - .into_iter() - .map(|b| Branch { + let project_path = format!("{}/{}", owner, repo); + let encoded_path = urlencoding::encode(&project_path); + + // GitLab defaults to 20 results per page; without an explicit pager the + // selected branch may fall outside the first page on busy repos and the + // UI then can't show it. Mirror the public-repo pattern: per_page=100, + // walk pages until short or we hit the 1000-branch safety cap. + let mut all_branches: Vec = Vec::new(); + let mut page: u32 = 1; + let per_page: usize = 100; + + loop { + let url = format!( + "{}/api/v4/projects/{}/repository/branches?per_page={}&page={}", + self.base_url, encoded_path, per_page, page + ); + + let response = self + .send_with_retry(|| client.get(&url).headers(headers.clone())) + .await?; + + if !response.status().is_success() { + return Err(GitProviderError::ApiError(format!( + "Failed to list branches for {}/{} (page {}): {}", + owner, + repo, + page, + response.status() + ))); + } + + let gitlab_branches: Vec = response + .json() + .await + .map_err(|e| GitProviderError::ApiError(e.to_string()))?; + + let count = gitlab_branches.len(); + all_branches.extend(gitlab_branches.into_iter().map(|b| Branch { name: b.name, commit_sha: b.commit.id, protected: b.protected, - }) - .collect(); + })); + + if count < per_page || all_branches.len() >= 1000 { + break; + } + page += 1; + } - Ok(branches) + Ok(all_branches) } async fn list_tags( @@ -698,28 +715,6 @@ impl GitProviderService for GitLabProvider { owner: &str, repo: &str, ) -> Result, GitProviderError> { - let client = self.get_client(); - let headers = self.get_headers(access_token); - - let project_path = format!("{}/{}", owner, repo); - let encoded_path = urlencoding::encode(&project_path); - - let url = format!( - "{}/api/v4/projects/{}/repository/tags", - self.base_url, encoded_path - ); - - let response = self - .send_with_retry(|| client.get(&url).headers(headers.clone())) - .await?; - - if !response.status().is_success() { - return Err(GitProviderError::ApiError(format!( - "Failed to list tags: {}", - response.status() - ))); - } - #[derive(Deserialize)] struct GitLabTag { name: String, @@ -731,19 +726,55 @@ impl GitProviderService for GitLabProvider { id: String, } - let gitlab_tags: Vec = response.json().await.map_err(|e| { - GitProviderError::ApiError(format!("Failed to parse tags response: {}", e)) - })?; + let client = self.get_client(); + let headers = self.get_headers(access_token); - let tags = gitlab_tags - .into_iter() - .map(|t| GitProviderTag { + let project_path = format!("{}/{}", owner, repo); + let encoded_path = urlencoding::encode(&project_path); + + // Paginate: GitLab's default 20-per-page truncates large tag sets. Cap + // at 1000 tags to bound memory on huge repos. + let mut all_tags: Vec = Vec::new(); + let mut page: u32 = 1; + let per_page: usize = 100; + + loop { + let url = format!( + "{}/api/v4/projects/{}/repository/tags?per_page={}&page={}", + self.base_url, encoded_path, per_page, page + ); + + let response = self + .send_with_retry(|| client.get(&url).headers(headers.clone())) + .await?; + + if !response.status().is_success() { + return Err(GitProviderError::ApiError(format!( + "Failed to list tags for {}/{} (page {}): {}", + owner, + repo, + page, + response.status() + ))); + } + + let gitlab_tags: Vec = response.json().await.map_err(|e| { + GitProviderError::ApiError(format!("Failed to parse tags response: {}", e)) + })?; + + let count = gitlab_tags.len(); + all_tags.extend(gitlab_tags.into_iter().map(|t| GitProviderTag { name: t.name, commit_sha: t.commit.id, - }) - .collect(); + })); + + if count < per_page || all_tags.len() >= 1000 { + break; + } + page += 1; + } - Ok(tags) + Ok(all_tags) } async fn get_file_content( diff --git a/crates/temps-git/src/services/gitlab_webhook.rs b/crates/temps-git/src/services/gitlab_webhook.rs new file mode 100644 index 000000000..bf46755a6 --- /dev/null +++ b/crates/temps-git/src/services/gitlab_webhook.rs @@ -0,0 +1,867 @@ +//! GitLab Webhook Management Service +//! +//! Handles auto-installation and removal of per-project GitLab webhooks. +//! Used by the project lifecycle (connect/disconnect) and the reinstall endpoint. + +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::{debug, info, warn}; + +#[derive(Error, Debug)] +pub enum GitLabWebhookError { + #[error("GitLab API error for project {project_path}: HTTP {status} — {body}")] + ApiError { + project_path: String, + status: u16, + body: String, + }, + + #[error( + "Insufficient permissions for project {project_path}: access_level={access_level} (need >= 40 Maintainer)" + )] + InsufficientPermissions { + project_path: String, + access_level: i32, + }, + + #[error("GitLab connection {connection_id} not found or has no access token")] + ConnectionNotFound { connection_id: i32 }, + + #[error("HTTP client error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), +} + +// ── API response shapes ────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct HookCreatedResponse { + id: i64, +} + +/// Minimal project info from `GET /projects/:id` +#[derive(Deserialize)] +struct GitLabProjectInfo { + permissions: Option, +} + +#[derive(Deserialize)] +struct GitLabPermissions { + project_access: Option, + group_access: Option, +} + +#[derive(Deserialize)] +struct GitLabAccessLevel { + access_level: i32, +} + +// ── Request body for creating a hook ──────────────────────────────────────── + +#[derive(Serialize)] +struct CreateHookBody<'a> { + url: &'a str, + /// HMAC-SHA256 signing token. GitLab sends `HMAC(body, signing_token)` in + /// the `webhook-signature: v1,{base64}` header on every event. Requires + /// GitLab 17.0+. The legacy plaintext `token` field is intentionally NOT + /// used — see the receiver in `handlers/gitlab.rs`. + signing_token: &'a str, + push_events: bool, + tag_push_events: bool, + enable_ssl_verification: bool, + merge_requests_events: bool, +} + +// ── Auth method ────────────────────────────────────────────────────────────── + +/// How the GitLab access token was acquired, which determines which HTTP +/// authentication header GitLab expects. +/// +/// GitLab officially supports two auth header styles: +/// - PAT / App tokens: `PRIVATE-TOKEN: ` +/// - OAuth 2.0 tokens: `Authorization: Bearer ` +/// +/// Using `PRIVATE-TOKEN` for OAuth tokens works on gitlab.com today but is +/// undocumented behaviour and breaks on stricter self-hosted instances. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebhookAuthMethod { + /// Personal Access Token or GitLab App token — uses `PRIVATE-TOKEN` header. + Pat, + /// OAuth 2.0 token — uses `Authorization: Bearer` header. + OAuth, + /// Any other / unknown auth method. Falls back to `PRIVATE-TOKEN` for + /// backwards-compatibility. + Other, +} + +impl WebhookAuthMethod { + /// Convert the string stored in `git_providers.auth_method` into the enum. + /// + /// Known values: `"app"`, `"oauth"`, `"pat"`, `"basic"`, `"ssh"`. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "oauth" => Self::OAuth, + "pat" | "app" => Self::Pat, + _ => Self::Other, + } + } +} + +// ── Service ────────────────────────────────────────────────────────────────── + +/// Thin client wrapper for the three GitLab webhook management calls. +/// All methods take a resolved `access_token` and the GitLab `base_url` +/// (e.g. `https://gitlab.com`); token resolution is the caller's job. +pub struct GitLabWebhookClient { + http: reqwest::Client, + base_url: String, + access_token: String, + auth_method: WebhookAuthMethod, +} + +impl GitLabWebhookClient { + /// Build a client from an already-resolved access token, base URL, and + /// the auth method that determines which HTTP header is used. + pub fn new(base_url: String, access_token: String, auth_method: WebhookAuthMethod) -> Self { + let http = reqwest::Client::builder() + .user_agent("Temps-Engine/1.0") + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build reqwest client with static config"); + + Self { + http, + base_url, + access_token, + auth_method, + } + } + + /// Returns the `(header-name, header-value)` pair appropriate for the + /// auth method stored on this client. + /// + /// - OAuth tokens must use `Authorization: Bearer ` (RFC 6750). + /// - PAT / App / unknown tokens use the GitLab-specific `PRIVATE-TOKEN` header. + fn auth_header(&self) -> (&'static str, String) { + match self.auth_method { + WebhookAuthMethod::OAuth => ("Authorization", format!("Bearer {}", self.access_token)), + WebhookAuthMethod::Pat | WebhookAuthMethod::Other => { + ("PRIVATE-TOKEN", self.access_token.clone()) + } + } + } + + fn encoded_path(owner: &str, repo: &str) -> String { + let path = format!("{}/{}", owner, repo); + urlencoding::encode(&path).to_string() + } + + /// `GET /projects/:id?with_custom_attributes=false` + /// Returns the caller's effective access level (project or group). + /// Returns 0 when the permission block is absent (public project, no token). + pub async fn get_project_access_level( + &self, + owner: &str, + repo: &str, + ) -> Result { + let encoded = Self::encoded_path(owner, repo); + let url = format!( + "{}/api/v4/projects/{}?with_custom_attributes=false", + self.base_url, encoded + ); + + let (header_name, header_value) = self.auth_header(); + let response = self + .http + .get(&url) + .header(header_name, &header_value) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(GitLabWebhookError::ApiError { + project_path: format!("{}/{}", owner, repo), + status: status.as_u16(), + body, + }); + } + + let info: GitLabProjectInfo = response.json().await?; + + // GitLab returns the *effective* access level under `permissions`. + // It can be split between project_access (direct member) and group_access + // (inherited from the group). We take the maximum of whichever are present. + let level = { + let project_level = info + .permissions + .as_ref() + .and_then(|p| p.project_access.as_ref()) + .map(|a| a.access_level) + .unwrap_or(0); + + let group_level = info + .permissions + .as_ref() + .and_then(|p| p.group_access.as_ref()) + .map(|a| a.access_level) + .unwrap_or(0); + + project_level.max(group_level) + }; + + debug!("GitLab access level for {}/{}: {}", owner, repo, level); + + Ok(level) + } + + /// `POST /projects/:id/hooks` — installs the webhook and returns the GitLab hook ID. + pub async fn install_webhook( + &self, + owner: &str, + repo: &str, + webhook_url: &str, + signing_token: &str, + ) -> Result { + let encoded = Self::encoded_path(owner, repo); + let url = format!("{}/api/v4/projects/{}/hooks", self.base_url, encoded); + + let body = CreateHookBody { + url: webhook_url, + signing_token, + push_events: true, + tag_push_events: true, + enable_ssl_verification: true, + merge_requests_events: false, + }; + + let (header_name, header_value) = self.auth_header(); + let response = self + .http + .post(&url) + .header(header_name, &header_value) + .json(&body) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let body_text = response.text().await.unwrap_or_default(); + return Err(GitLabWebhookError::ApiError { + project_path: format!("{}/{}", owner, repo), + status: status.as_u16(), + body: body_text, + }); + } + + let created: HookCreatedResponse = response.json().await?; + info!( + "Installed GitLab webhook {} for {}/{}", + created.id, owner, repo + ); + Ok(created.id) + } + + /// `DELETE /projects/:id/hooks/:hook_id` — idempotent on 404. + pub async fn delete_webhook( + &self, + owner: &str, + repo: &str, + hook_id: i64, + ) -> Result<(), GitLabWebhookError> { + let encoded = Self::encoded_path(owner, repo); + let url = format!( + "{}/api/v4/projects/{}/hooks/{}", + self.base_url, encoded, hook_id + ); + + let (header_name, header_value) = self.auth_header(); + let response = self + .http + .delete(&url) + .header(header_name, &header_value) + .send() + .await?; + + let status = response.status(); + + // 404 → already gone, treat as success (idempotent) + if status == StatusCode::NOT_FOUND { + warn!( + "GitLab webhook {} for {}/{} was already absent (404), treating as success", + hook_id, owner, repo + ); + return Ok(()); + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(GitLabWebhookError::ApiError { + project_path: format!("{}/{}", owner, repo), + status: status.as_u16(), + body, + }); + } + + info!("Deleted GitLab webhook {} for {}/{}", hook_id, owner, repo); + Ok(()) + } +} + +// ── HMAC-SHA256 signature helpers ──────────────────────────────────────────── + +/// Verify a GitLab `webhook-signature` header against a raw payload, the +/// `webhook-id` and `webhook-timestamp` request headers, and the stored +/// signing token. +/// +/// GitLab 19.0+ uses Standard Webhooks-style signing: +/// - The signing token is `whsec_{base64(key)}` where `key` is 32 bytes. +/// - The signed message is the byte string `{webhook_id}.{timestamp}.{body}`. +/// - The signature header is `webhook-signature: v1,{base64(HMAC-SHA256(message, key))}`. +/// - Multiple signatures may appear space-separated in the header +/// (e.g. during key rotation): `v1,sigA v1,sigB`. We accept the message +/// if ANY of them validates. +/// +/// Returns `true` only when at least one signature matches. +pub fn verify_gitlab_signature( + payload: &[u8], + header_value: &str, + webhook_id: &str, + timestamp: &str, + signing_token: &str, +) -> bool { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + // Derive the HMAC key from the `whsec_` token. + let b64_key = match signing_token.strip_prefix("whsec_") { + Some(rest) => rest, + // Reject tokens without the required prefix — they were never valid + // for GitLab to sign with, so any signature must be forged. + None => return false, + }; + let key = match STANDARD.decode(b64_key) { + Ok(k) => k, + Err(_) => return false, + }; + + // Build the message: "{webhook_id}.{timestamp}.{body}". + // The id+timestamp parts are ASCII strings; we concatenate as bytes. + let mut message: Vec = + Vec::with_capacity(webhook_id.len() + 1 + timestamp.len() + 1 + payload.len()); + message.extend_from_slice(webhook_id.as_bytes()); + message.push(b'.'); + message.extend_from_slice(timestamp.as_bytes()); + message.push(b'.'); + message.extend_from_slice(payload); + + // The header may carry multiple signatures separated by spaces. Accept + // the request if any of them validates — this covers GitLab's key + // rotation window. + for sig_token in header_value.split_whitespace() { + let b64_part = match sig_token.split_once(',') { + Some((_version, sig)) => sig, + None => sig_token, + }; + let decoded = match STANDARD.decode(b64_part) { + Ok(d) => d, + Err(_) => continue, + }; + let mut mac = match Hmac::::new_from_slice(&key) { + Ok(m) => m, + Err(_) => continue, + }; + mac.update(&message); + if mac.verify_slice(&decoded).is_ok() { + return true; + } + } + false +} + +/// Generate a signing token in the format GitLab requires. +/// +/// GitLab validates `signing_token` against the regex `^whsec_[A-Za-z0-9+/=]+$` +/// where the base64-decoded portion must be 32 bytes. We produce +/// `whsec_{base64(32_random_bytes)}` to satisfy that constraint. +/// +/// The full string (including the `whsec_` prefix) is what we store and what +/// the verifier uses to derive the HMAC key — see `verify_gitlab_signature`. +pub fn generate_signing_token() -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + format!("whsec_{}", STANDARD.encode(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + // ── helpers ─────────────────────────────────────────────────────────── + + /// Build a valid `v1,{base64}` Standard-Webhooks signature for a given + /// payload + headers + signing token. Used in tests to produce a header + /// the verifier should accept. + fn make_gitlab_signature( + payload: &[u8], + webhook_id: &str, + timestamp: &str, + signing_token: &str, + ) -> String { + // Reproduce the verifier's key-derivation: strip `whsec_`, base64-decode. + let b64_key = signing_token + .strip_prefix("whsec_") + .expect("test signing tokens must use the whsec_ prefix"); + let key = STANDARD + .decode(b64_key) + .expect("test key must be valid base64"); + + let mut message: Vec = Vec::new(); + message.extend_from_slice(webhook_id.as_bytes()); + message.push(b'.'); + message.extend_from_slice(timestamp.as_bytes()); + message.push(b'.'); + message.extend_from_slice(payload); + + let mut mac = Hmac::::new_from_slice(&key).expect("HMAC accepts any key length"); + mac.update(&message); + let code_bytes = mac.finalize().into_bytes(); + format!("v1,{}", STANDARD.encode(code_bytes)) + } + + /// A fixed valid token for tests (32 zero bytes, base64-encoded). + fn test_token() -> String { + format!("whsec_{}", STANDARD.encode([0u8; 32])) + } + + // ── verify_gitlab_signature (Standard-Webhooks path) ────────────────── + + #[test] + fn test_verify_gitlab_signature_valid_v1_header() { + let payload = b"push event body"; + let token = test_token(); + let header = make_gitlab_signature(payload, "wh_abc", "1700000000", &token); + assert!( + verify_gitlab_signature(payload, &header, "wh_abc", "1700000000", &token), + "v1, header with correct key, id, and timestamp must verify" + ); + } + + #[test] + fn test_verify_gitlab_signature_wrong_key_rejects() { + let payload = b"push event body"; + let real_token = test_token(); + let other_token = format!("whsec_{}", STANDARD.encode([1u8; 32])); + let header = make_gitlab_signature(payload, "wh_a", "1700000000", &real_token); + assert!( + !verify_gitlab_signature(payload, &header, "wh_a", "1700000000", &other_token), + "signature computed with a different key must be rejected" + ); + } + + #[test] + fn test_verify_gitlab_signature_tampered_payload_rejects() { + let token = test_token(); + let header = make_gitlab_signature(b"original", "wh_a", "1700000000", &token); + assert!( + !verify_gitlab_signature(b"tampered", &header, "wh_a", "1700000000", &token), + "signature over original payload must not validate a different payload" + ); + } + + #[test] + fn test_verify_gitlab_signature_wrong_timestamp_rejects() { + let token = test_token(); + let payload = b"body"; + let header = make_gitlab_signature(payload, "wh_a", "1700000000", &token); + assert!( + !verify_gitlab_signature(payload, &header, "wh_a", "1700000099", &token), + "different timestamp must invalidate the signature" + ); + } + + #[test] + fn test_verify_gitlab_signature_wrong_webhook_id_rejects() { + let token = test_token(); + let payload = b"body"; + let header = make_gitlab_signature(payload, "wh_a", "1700000000", &token); + assert!( + !verify_gitlab_signature(payload, &header, "wh_b", "1700000000", &token), + "different webhook-id must invalidate the signature" + ); + } + + #[test] + fn test_verify_gitlab_signature_token_without_whsec_prefix_rejects() { + // A signing token missing the `whsec_` prefix is malformed and + // cannot have produced any GitLab signature. + let bad_token = STANDARD.encode([0u8; 32]); // no prefix + assert!( + !verify_gitlab_signature(b"body", "v1,xxx", "wh_a", "1700000000", &bad_token), + "signing token without whsec_ prefix must be rejected" + ); + } + + #[test] + fn test_verify_gitlab_signature_invalid_base64_rejects() { + let token = test_token(); + assert!( + !verify_gitlab_signature( + b"payload", + "v1,!!!not-valid-base64!!!", + "wh_a", + "1700000000", + &token, + ), + "invalid base64 must be rejected without panicking" + ); + } + + #[test] + fn test_verify_gitlab_signature_empty_header_rejects() { + let token = test_token(); + assert!( + !verify_gitlab_signature(b"payload", "", "wh_a", "1700000000", &token), + "empty header must be rejected" + ); + } + + #[test] + fn test_verify_gitlab_signature_multiple_signatures_one_valid() { + // Header carries two signatures (e.g. during key rotation). + // The first is junk, the second is correct. + let token = test_token(); + let payload = b"body"; + let valid = make_gitlab_signature(payload, "wh_a", "1700000000", &token); + let combined = format!("v1,{} {}", STANDARD.encode([0u8; 32]), valid); + assert!( + verify_gitlab_signature(payload, &combined, "wh_a", "1700000000", &token), + "if any space-separated signature validates, the request must be accepted" + ); + } + + #[test] + fn test_generate_signing_token_has_whsec_prefix() { + let token = generate_signing_token(); + assert!( + token.starts_with("whsec_"), + "GitLab requires the whsec_ prefix" + ); + let b64 = token.strip_prefix("whsec_").unwrap(); + let decoded = STANDARD.decode(b64).expect("must be valid base64"); + assert_eq!( + decoded.len(), + 32, + "the base64-decoded portion must be exactly 32 bytes" + ); + } + + #[test] + fn test_generate_signing_token_is_unique() { + let t1 = generate_signing_token(); + let t2 = generate_signing_token(); + assert_ne!(t1, t2, "tokens must be unique (random)"); + } + + // ── encoded_path helper ─────────────────────────────────────────────── + + #[test] + fn test_encoded_path_simple() { + let enc = GitLabWebhookClient::encoded_path("myorg", "myrepo"); + assert_eq!(enc, "myorg%2Fmyrepo"); + } + + #[test] + fn test_encoded_path_nested_group() { + let enc = GitLabWebhookClient::encoded_path("group/subgroup", "repo"); + // owner is already a slash-path; combined it becomes group/subgroup/repo + // which urlencodes as group%2Fsubgroup%2Frepo + assert_eq!(enc, "group%2Fsubgroup%2Frepo"); + } + + // ── HTTP client integration tests (require network mock) ────────────── + // + // These tests use `mockito` to stand up a local HTTP server so we can + // verify the correct endpoints are called without hitting real GitLab. + + // ── WebhookAuthMethod::from_str ─────────────────────────────────────── + + #[test] + fn test_auth_method_from_str_oauth() { + assert_eq!( + WebhookAuthMethod::from_str("oauth"), + WebhookAuthMethod::OAuth + ); + } + + #[test] + fn test_auth_method_from_str_pat() { + assert_eq!(WebhookAuthMethod::from_str("pat"), WebhookAuthMethod::Pat); + } + + #[test] + fn test_auth_method_from_str_app() { + assert_eq!(WebhookAuthMethod::from_str("app"), WebhookAuthMethod::Pat); + } + + #[test] + fn test_auth_method_from_str_unknown_falls_back_to_other() { + assert_eq!( + WebhookAuthMethod::from_str("basic"), + WebhookAuthMethod::Other + ); + assert_eq!(WebhookAuthMethod::from_str("ssh"), WebhookAuthMethod::Other); + assert_eq!(WebhookAuthMethod::from_str(""), WebhookAuthMethod::Other); + } + + // ── auth_header selection ───────────────────────────────────────────── + + #[tokio::test] + async fn test_pat_uses_private_token_header() { + let mut server = mockito::Server::new_async().await; + // Expect the PRIVATE-TOKEN header to be present with the right value. + let mock = server + .mock("POST", "/api/v4/projects/myorg%2Fmyrepo/hooks") + .match_header("PRIVATE-TOKEN", "my-pat-token") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"id": 10}"#) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "my-pat-token".to_string(), + WebhookAuthMethod::Pat, + ); + client + .install_webhook("myorg", "myrepo", "https://example.com/wh", "secret") + .await + .expect("install should succeed with PAT"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_oauth_uses_bearer_authorization_header() { + let mut server = mockito::Server::new_async().await; + // Expect `Authorization: Bearer ` — NOT `PRIVATE-TOKEN`. + let mock = server + .mock("POST", "/api/v4/projects/myorg%2Fmyrepo/hooks") + .match_header("Authorization", "Bearer my-oauth-token") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"id": 20}"#) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "my-oauth-token".to_string(), + WebhookAuthMethod::OAuth, + ); + client + .install_webhook("myorg", "myrepo", "https://example.com/wh", "secret") + .await + .expect("install should succeed with OAuth Bearer"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_other_auth_method_falls_back_to_private_token() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/api/v4/projects/myorg%2Fmyrepo/hooks") + .match_header("PRIVATE-TOKEN", "unknown-token") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"id": 30}"#) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "unknown-token".to_string(), + WebhookAuthMethod::Other, + ); + client + .install_webhook("myorg", "myrepo", "https://example.com/wh", "secret") + .await + .expect("install should succeed with Other (fallback to PRIVATE-TOKEN)"); + + mock.assert_async().await; + } + + // ── HTTP client integration tests (require network mock) ────────────── + // + // These tests use `mockito` to stand up a local HTTP server so we can + // verify the correct endpoints are called without hitting real GitLab. + + #[tokio::test] + async fn test_install_webhook_happy_path() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/api/v4/projects/myorg%2Fmyrepo/hooks") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"id": 42}"#) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "test-token".to_string(), + WebhookAuthMethod::Pat, + ); + let hook_id = client + .install_webhook("myorg", "myrepo", "https://example.com/wh", "secret") + .await + .expect("install should succeed"); + + assert_eq!(hook_id, 42); + mock.assert_async().await; + } + + #[tokio::test] + async fn test_delete_webhook_happy_path() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("DELETE", "/api/v4/projects/myorg%2Fmyrepo/hooks/99") + .with_status(204) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "test-token".to_string(), + WebhookAuthMethod::Pat, + ); + client + .delete_webhook("myorg", "myrepo", 99) + .await + .expect("delete should succeed"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_delete_webhook_idempotent_on_404() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("DELETE", "/api/v4/projects/myorg%2Fmyrepo/hooks/77") + .with_status(404) + .create_async() + .await; + + let client = GitLabWebhookClient::new( + server.url(), + "test-token".to_string(), + WebhookAuthMethod::Pat, + ); + // Should NOT return an error on 404. + client + .delete_webhook("myorg", "myrepo", 77) + .await + .expect("delete on 404 should succeed (idempotent)"); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_get_project_access_level_maintainer() { + let body = r#"{ + "permissions": { + "project_access": {"access_level": 40}, + "group_access": null + } + }"#; + + let mut server = mockito::Server::new_async().await; + let mock = server + .mock( + "GET", + "/api/v4/projects/myorg%2Fmyrepo?with_custom_attributes=false", + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body) + .create_async() + .await; + + let client = + GitLabWebhookClient::new(server.url(), "tok".to_string(), WebhookAuthMethod::Pat); + let level = client + .get_project_access_level("myorg", "myrepo") + .await + .expect("access level call should succeed"); + + assert_eq!(level, 40); + mock.assert_async().await; + } + + #[tokio::test] + async fn test_get_project_access_level_developer_only() { + // Developer has access_level 30, which is below the Maintainer threshold. + let body = r#"{ + "permissions": { + "project_access": {"access_level": 30}, + "group_access": null + } + }"#; + + let mut server = mockito::Server::new_async().await; + server + .mock( + "GET", + "/api/v4/projects/myorg%2Fmyrepo?with_custom_attributes=false", + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body) + .create_async() + .await; + + let client = + GitLabWebhookClient::new(server.url(), "tok".to_string(), WebhookAuthMethod::Pat); + let level = client + .get_project_access_level("myorg", "myrepo") + .await + .expect("call should succeed"); + + assert!(level < 40, "developer should be below Maintainer threshold"); + } + + #[tokio::test] + async fn test_install_webhook_server_error_returns_err() { + let mut server = mockito::Server::new_async().await; + // Simulate GitLab returning 500. + server + .mock("POST", "/api/v4/projects/myorg%2Fmyrepo/hooks") + .with_status(500) + .with_body("Internal Server Error") + .create_async() + .await; + + let client = + GitLabWebhookClient::new(server.url(), "tok".to_string(), WebhookAuthMethod::Pat); + let result = client + .install_webhook("myorg", "myrepo", "https://example.com/wh", "secret") + .await; + + assert!(result.is_err(), "500 should be an error"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("500") || err.contains("Internal Server Error")); + } +} diff --git a/crates/temps-git/src/services/mod.rs b/crates/temps-git/src/services/mod.rs index e65bcb261..964e625ad 100644 --- a/crates/temps-git/src/services/mod.rs +++ b/crates/temps-git/src/services/mod.rs @@ -9,5 +9,6 @@ pub mod git_service; pub mod github; pub mod github_provider; pub mod gitlab_provider; +pub mod gitlab_webhook; pub mod public_repo; pub mod repository; diff --git a/crates/temps-migrations/src/migration/m20260430_000001_add_deployment_container_exit_info.rs b/crates/temps-migrations/src/migration/m20260430_000001_add_deployment_container_exit_info.rs new file mode 100644 index 000000000..007e15dd6 --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260430_000001_add_deployment_container_exit_info.rs @@ -0,0 +1,71 @@ +use sea_orm_migration::prelude::*; + +/// Capture WHY a container exited so the UI can show "OOMKilled" or +/// "Exited(137) — SIGKILL" instead of a bare "Exited" pill. All columns are +/// nullable: existing rows and currently-running containers don't have an +/// exit yet, and Docker may not surface every field for every shutdown. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(DeploymentContainers::Table) + .add_column( + ColumnDef::new(DeploymentContainers::ExitCode) + .integer() + .null(), + ) + .add_column( + ColumnDef::new(DeploymentContainers::ExitReason) + .string_len(255) + .null(), + ) + .add_column( + ColumnDef::new(DeploymentContainers::OomKilled) + .boolean() + .null(), + ) + .add_column( + ColumnDef::new(DeploymentContainers::ErrorMessage) + .text() + .null(), + ) + .add_column( + ColumnDef::new(DeploymentContainers::FinishedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(DeploymentContainers::Table) + .drop_column(DeploymentContainers::ExitCode) + .drop_column(DeploymentContainers::ExitReason) + .drop_column(DeploymentContainers::OomKilled) + .drop_column(DeploymentContainers::ErrorMessage) + .drop_column(DeploymentContainers::FinishedAt) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum DeploymentContainers { + Table, + ExitCode, + ExitReason, + OomKilled, + ErrorMessage, + FinishedAt, +} diff --git a/crates/temps-migrations/src/migration/m20260430_000002_add_deployment_container_runtime_info.rs b/crates/temps-migrations/src/migration/m20260430_000002_add_deployment_container_runtime_info.rs new file mode 100644 index 000000000..d326a8e4b --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260430_000002_add_deployment_container_runtime_info.rs @@ -0,0 +1,55 @@ +use sea_orm_migration::prelude::*; + +/// Adds the runtime-state columns that complement the exit-info columns from +/// m20260430_000001. Split into its own migration because the first one +/// already shipped to some environments — re-running an `alter_table` with +/// extra columns is risky, so we add these two independently and idempotently. +/// +/// `started_at` lets the UI show uptime that resets on a restart-in-place +/// (distinct from `created_at`), and `cpu_limit_cores` lets the configuration +/// tab surface the actual Docker-applied CPU cap so drift between configured +/// and observed limits is visible. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(DeploymentContainers::Table) + .add_column_if_not_exists( + ColumnDef::new(DeploymentContainers::StartedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column_if_not_exists( + ColumnDef::new(DeploymentContainers::CpuLimitCores) + .double() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(DeploymentContainers::Table) + .drop_column(DeploymentContainers::StartedAt) + .drop_column(DeploymentContainers::CpuLimitCores) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum DeploymentContainers { + Table, + StartedAt, + CpuLimitCores, +} diff --git a/crates/temps-migrations/src/migration/m20260501_000001_add_gitlab_webhook_to_projects.rs b/crates/temps-migrations/src/migration/m20260501_000001_add_gitlab_webhook_to_projects.rs new file mode 100644 index 000000000..0cee5c149 --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260501_000001_add_gitlab_webhook_to_projects.rs @@ -0,0 +1,62 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .add_column(ColumnDef::new(Projects::GitlabWebhookId).integer().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .add_column( + ColumnDef::new(Projects::GitlabWebhookSigningToken) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .drop_column(Projects::GitlabWebhookSigningToken) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Projects::Table) + .drop_column(Projects::GitlabWebhookId) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Projects { + Table, + GitlabWebhookId, + GitlabWebhookSigningToken, +} diff --git a/crates/temps-migrations/src/migration/mod.rs b/crates/temps-migrations/src/migration/mod.rs index 647fa0c25..bbbc62aef 100644 --- a/crates/temps-migrations/src/migration/mod.rs +++ b/crates/temps-migrations/src/migration/mod.rs @@ -78,6 +78,9 @@ mod m20260427_000004_add_provisioning_to_service_members; mod m20260428_000001_unique_member_ordinal; mod m20260428_000002_dns_owner_kind_deployment; mod m20260428_000003_create_node_route_state; +mod m20260430_000001_add_deployment_container_exit_info; +mod m20260430_000002_add_deployment_container_runtime_info; +mod m20260501_000001_add_gitlab_webhook_to_projects; pub struct Migrator; @@ -159,6 +162,9 @@ impl MigratorTrait for Migrator { Box::new(m20260428_000001_unique_member_ordinal::Migration), Box::new(m20260428_000002_dns_owner_kind_deployment::Migration), Box::new(m20260428_000003_create_node_route_state::Migration), + Box::new(m20260430_000001_add_deployment_container_exit_info::Migration), + Box::new(m20260430_000002_add_deployment_container_runtime_info::Migration), + Box::new(m20260501_000001_add_gitlab_webhook_to_projects::Migration), ] } } diff --git a/crates/temps-monitoring/src/alarm_service.rs b/crates/temps-monitoring/src/alarm_service.rs index 70334c214..912e2f238 100644 --- a/crates/temps-monitoring/src/alarm_service.rs +++ b/crates/temps-monitoring/src/alarm_service.rs @@ -26,6 +26,9 @@ use tracing::{error, info}; pub enum AlarmType { ContainerRestart, ContainerOomKilled, + /// Container exited or died for a reason other than OOM (non-zero exit code, + /// killed by signal, Docker reported error, etc.). + ContainerCrash, HighResponseTime, Outage, HighCpu, @@ -39,6 +42,7 @@ impl AlarmType { match self { Self::ContainerRestart => "container_restart", Self::ContainerOomKilled => "container_oom_killed", + Self::ContainerCrash => "container_crash", Self::HighResponseTime => "high_response_time", Self::Outage => "outage", Self::HighCpu => "high_cpu", @@ -52,6 +56,7 @@ impl AlarmType { match s { "container_restart" => Some(Self::ContainerRestart), "container_oom_killed" => Some(Self::ContainerOomKilled), + "container_crash" => Some(Self::ContainerCrash), "high_response_time" => Some(Self::HighResponseTime), "outage" => Some(Self::Outage), "high_cpu" => Some(Self::HighCpu), diff --git a/crates/temps-monitoring/src/container_health.rs b/crates/temps-monitoring/src/container_health.rs index 655203696..6e5cbb710 100644 --- a/crates/temps-monitoring/src/container_health.rs +++ b/crates/temps-monitoring/src/container_health.rs @@ -7,7 +7,7 @@ //! - Containers that exited unexpectedly use crate::alarm_service::{AlarmService, AlarmSeverity, AlarmType, FireAlarmRequest}; -use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use std::collections::HashMap; use std::sync::Arc; use temps_deployer::ContainerDeployer; @@ -173,6 +173,11 @@ impl ContainerHealthMonitor { self.check_restart_count(container, &deployment, &info) .await; + // Persist runtime metadata (started_at, cpu_limit_cores) once they're + // observed. These don't change while a container is running, so the + // diff check in persist_runtime_info skips writes after the first hit. + self.persist_runtime_info(container, &info).await; + // Check container status (exited, dead, OOM) self.check_container_status(container, &deployment, &info) .await; @@ -183,6 +188,36 @@ impl ContainerHealthMonitor { Ok(()) } + /// Capture started_at and cpu_limit_cores onto the row so the UI can show + /// uptime + configured limits even when the container is stopped (the + /// live SSE stream isn't running in that state). + async fn persist_runtime_info( + &self, + container: &deployment_containers::Model, + info: &temps_deployer::ContainerInfo, + ) { + let unchanged = container.started_at == info.started_at + && container.cpu_limit_cores == info.cpu_limit_cores; + if unchanged { + return; + } + let active = deployment_containers::ActiveModel { + id: Set(container.id), + started_at: Set(info.started_at), + cpu_limit_cores: Set(info.cpu_limit_cores), + ..Default::default() + }; + if let Err(e) = deployment_containers::Entity::update(active) + .exec(self.db.as_ref()) + .await + { + error!( + "Failed to persist runtime info for container {} ({}): {}", + container.id, container.container_name, e + ); + } + } + /// Detect restart count increases and fire alarms async fn check_restart_count( &self, @@ -276,6 +311,11 @@ impl ContainerHealthMonitor { match &info.status { temps_deployer::ContainerStatus::Exited | temps_deployer::ContainerStatus::Dead => { + // Persist the exit metadata first so the UI/API can surface + // *why* even if the alarm path is skipped (e.g. on-demand + // sleep). + self.persist_exit_info(container, info).await; + // Skip alarm if this is an on-demand environment that was intentionally // put to sleep. The on-demand manager stops containers on idle — that's // expected, not an error. @@ -288,15 +328,27 @@ impl ContainerHealthMonitor { } warn!( - "Container {} ({}) is in '{}' state", - container.id, container.container_name, status_str + "Container {} ({}) is in '{}' state (reason: {})", + container.id, + container.container_name, + status_str, + info.exit_reason.as_deref().unwrap_or("unknown") ); - // Determine if it's an OOM kill by checking metadata - // Docker sets OOMKilled in state, which we can check via restart_count behavior - let alarm_type = AlarmType::ContainerOomKilled; + // Pick OOM alarm only when Docker actually flagged OOMKilled; + // a plain non-zero exit is a different signal. + let alarm_type = if info.oom_killed == Some(true) { + AlarmType::ContainerOomKilled + } else { + AlarmType::ContainerCrash + }; let severity = AlarmSeverity::Critical; + let exit_reason_str = info + .exit_reason + .clone() + .unwrap_or_else(|| status_str.clone()); + let request = FireAlarmRequest { project_id: deployment.project_id, environment_id: deployment.environment_id, @@ -304,15 +356,23 @@ impl ContainerHealthMonitor { container_id: Some(container.id), alarm_type, severity, - title: format!("Container '{}' is {}", container.container_name, status_str), + title: format!( + "Container '{}' is {}: {}", + container.container_name, status_str, exit_reason_str + ), message: format!( - "Container '{}' has exited or died unexpectedly. Status: {}", - container.container_name, status_str + "Container '{}' has exited or died unexpectedly. Status: {}. Reason: {}", + container.container_name, status_str, exit_reason_str ), metadata: Some(serde_json::json!({ "container_name": container.container_name, "container_id": container.container_id, "status": status_str, + "exit_code": info.exit_code, + "exit_reason": info.exit_reason, + "oom_killed": info.oom_killed, + "error_message": info.error_message, + "finished_at": info.finished_at.map(|d| d.to_rfc3339()), })), }; @@ -329,6 +389,47 @@ impl ContainerHealthMonitor { } } + /// Write Docker's exit metadata onto the deployment_containers row so the + /// API can return it long after the alarm fires. Skips writes when nothing + /// changed, so this is safe to call every poll cycle. + async fn persist_exit_info( + &self, + container: &deployment_containers::Model, + info: &temps_deployer::ContainerInfo, + ) { + let new_status = Some(info.status.to_string()); + let unchanged = container.status == new_status + && container.exit_code == info.exit_code + && container.exit_reason == info.exit_reason + && container.oom_killed == info.oom_killed + && container.error_message == info.error_message + && container.finished_at == info.finished_at; + if unchanged { + return; + } + + let active = deployment_containers::ActiveModel { + id: Set(container.id), + status: Set(new_status), + exit_code: Set(info.exit_code), + exit_reason: Set(info.exit_reason.clone()), + oom_killed: Set(info.oom_killed), + error_message: Set(info.error_message.clone()), + finished_at: Set(info.finished_at), + ..Default::default() + }; + + if let Err(e) = deployment_containers::Entity::update(active) + .exec(self.db.as_ref()) + .await + { + error!( + "Failed to persist exit info for container {} ({}): {}", + container.id, container.container_name, e + ); + } + } + /// Check if an environment is currently sleeping (on-demand scale-to-zero). /// When sleeping=true, containers were intentionally stopped — not a crash. async fn is_on_demand_sleeping(&self, environment_id: i32) -> bool { @@ -555,6 +656,7 @@ mod tests { environment_vars: std::collections::HashMap::new(), restart_count: Some(restart_count), labels: std::collections::HashMap::new(), + ..Default::default() }), stats: tokio::sync::Mutex::new(ContainerStats { container_id: "abc123".to_string(), @@ -566,6 +668,7 @@ mod tests { network_rx_bytes: 0, network_tx_bytes: 0, timestamp: chrono::Utc::now(), + ..Default::default() }), } } @@ -646,6 +749,13 @@ mod tests { ready_at: None, deleted_at: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, } } diff --git a/crates/temps-notifications/src/services.rs b/crates/temps-notifications/src/services.rs index 988dfe217..b6aec6475 100644 --- a/crates/temps-notifications/src/services.rs +++ b/crates/temps-notifications/src/services.rs @@ -714,16 +714,59 @@ impl NotificationService { Ok(providers) } - fn get_next_allowed_time(priority: &NotificationPriority) -> DateTime { - let now = Utc::now(); + /// Returns the base delay between notifications for a given priority. + /// This is the gap after the very first notification — subsequent gaps + /// grow exponentially (see `get_next_allowed_time`). + fn base_delay(priority: &NotificationPriority) -> Duration { match priority { - NotificationPriority::Low => now + Duration::days(7), - NotificationPriority::Normal => now + Duration::days(1), - NotificationPriority::High => now + Duration::hours(1), - NotificationPriority::Critical => now + Duration::minutes(15), + NotificationPriority::Low => Duration::days(7), + NotificationPriority::Normal => Duration::days(1), + NotificationPriority::High => Duration::hours(1), + NotificationPriority::Critical => Duration::minutes(15), } } + /// Returns the maximum gap between notifications for a given priority. + /// Exponential backoff is clamped here so a long-running incident still + /// produces an occasional reminder rather than going completely silent. + fn max_delay(priority: &NotificationPriority) -> Duration { + match priority { + NotificationPriority::Low => Duration::days(30), + NotificationPriority::Normal => Duration::days(7), + NotificationPriority::High => Duration::hours(24), + NotificationPriority::Critical => Duration::hours(24), + } + } + + /// Compute the next-allowed timestamp using exponential backoff. + /// + /// `previous_attempts` is the `occurrence_count` of the previous record for + /// this batch_key — i.e., how many times the same alarm tried to fire + /// (1 send + N throttled events) before this send. The first ever send + /// passes 0 and gets the base delay; persistent incidents grow the gap + /// exponentially, clamped at `max_delay`. This stops a flapping container + /// from generating one email every 15 minutes forever. + fn get_next_allowed_time( + priority: &NotificationPriority, + previous_attempts: i32, + ) -> DateTime { + let base = Self::base_delay(priority); + let cap = Self::max_delay(priority); + + // First-time send (no prior attempts) uses the base delay. + // Otherwise double per prior attempt: 1 prior -> 2x, 2 prior -> 4x, etc. + // Clamp shift at 20 so we never overflow i64 before clamping to `cap`. + let shift = previous_attempts.clamp(0, 20) as u32; + let multiplier: i64 = 1i64 << shift; + + let scaled_secs = base + .num_seconds() + .saturating_mul(multiplier) + .min(cap.num_seconds()); + + Utc::now() + Duration::seconds(scaled_secs) + } + pub async fn send_notification(&self, notification: Notification) -> Result<()> { let now = Utc::now(); let batch_key_str = Self::get_batch_key(¬ification); @@ -736,25 +779,32 @@ impl NotificationService { .await?; if let Some(existing) = existing.clone() { - // If we have a similar notification, check if we should send it or batch it - if now < existing.next_allowed_at { + // If we have a similar notification, check if we should send it or batch it. + // `bypass_throttling` lets callers (e.g. weekly digest, manual test sends) + // skip the gate entirely. Critical alarms intentionally still respect the + // backoff — bypassing them is what produced 100 emails/day. + if !notification.bypass_throttling && now < existing.next_allowed_at { // Update occurrence count and return let mut existing_update: notifications::ActiveModel = existing.clone().into(); existing_update.occurrence_count = Set(existing.occurrence_count + 1); existing_update.update(self.db.as_ref()).await?; info!( - "Batching notification '{}'. Current count: {}", + "Batching notification '{}'. Current count: {}, next send allowed at {}", notification.title, - existing.occurrence_count + 1 + existing.occurrence_count + 1, + existing.next_allowed_at, ); return Ok(()); } } - // If we reach here, we should send the notification + // If we reach here, we should send the notification. + // Pass the previous record's occurrence_count so the gap doubles per + // ongoing-incident attempt instead of staying at the base delay forever. + let previous_attempts = existing.as_ref().map(|e| e.occurrence_count).unwrap_or(0); let metadata_json = serde_json::to_string(¬ification.metadata)?; - let next_allowed = Self::get_next_allowed_time(¬ification.priority); + let next_allowed = Self::get_next_allowed_time(¬ification.priority, previous_attempts); // Create new notification record let new_notification = notifications::ActiveModel { @@ -1348,22 +1398,61 @@ mod tests { assert_eq!(key, "Info:Normal:Test Notification"); } + /// Helper: assert `actual` falls within `expected ± tolerance_secs`. + /// Accounts for the few microseconds between the test's `Utc::now()` + /// snapshot and the inner call inside `get_next_allowed_time`. + fn assert_near(actual: DateTime, expected: DateTime, tolerance_secs: i64) { + let diff = (actual - expected).num_seconds().abs(); + assert!( + diff <= tolerance_secs, + "actual {} differs from expected {} by {}s (tolerance {}s)", + actual, + expected, + diff, + tolerance_secs + ); + } + + #[test] + fn test_next_allowed_time_base_delay_first_send() { + // First send (previous_attempts = 0) should equal the base delay. + let now = Utc::now(); + + let low = NotificationService::get_next_allowed_time(&NotificationPriority::Low, 0); + let normal = NotificationService::get_next_allowed_time(&NotificationPriority::Normal, 0); + let high = NotificationService::get_next_allowed_time(&NotificationPriority::High, 0); + let critical = + NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 0); + + assert_near(low, now + Duration::days(7), 5); + assert_near(normal, now + Duration::days(1), 5); + assert_near(high, now + Duration::hours(1), 5); + assert_near(critical, now + Duration::minutes(15), 5); + } + + #[test] + fn test_next_allowed_time_doubles_per_attempt() { + // Critical: base 15m. After 1 prior attempt -> 30m. After 2 -> 1h. After 3 -> 2h. + let now = Utc::now(); + + let one = NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 1); + let two = NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 2); + let three = NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 3); + + assert_near(one, now + Duration::minutes(30), 5); + assert_near(two, now + Duration::hours(1), 5); + assert_near(three, now + Duration::hours(2), 5); + } + #[test] - fn test_next_allowed_time_calculation() { + fn test_next_allowed_time_clamped_at_max() { + // Critical: cap is 24h. Even an absurd attempt count must not exceed it. let now = Utc::now(); - let low_priority = NotificationService::get_next_allowed_time(&NotificationPriority::Low); - let normal_priority = - NotificationService::get_next_allowed_time(&NotificationPriority::Normal); - let high_priority = NotificationService::get_next_allowed_time(&NotificationPriority::High); - let critical_priority = - NotificationService::get_next_allowed_time(&NotificationPriority::Critical); + let huge = + NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 1000); - // Check relative times - assert!(low_priority > now + Duration::days(6)); - assert!(normal_priority > now + Duration::hours(23)); - assert!(high_priority > now + Duration::minutes(59)); - assert!(critical_priority > now + Duration::minutes(14)); + assert_near(huge, now + Duration::hours(24), 5); } #[test] @@ -1535,13 +1624,15 @@ mod tests { #[test] fn test_notification_priority_ordering() { - let low_time = NotificationService::get_next_allowed_time(&NotificationPriority::Low); - let normal_time = NotificationService::get_next_allowed_time(&NotificationPriority::Normal); - let high_time = NotificationService::get_next_allowed_time(&NotificationPriority::High); + // For a first send (no prior attempts), Critical should have the shortest + // wait time and Low the longest. + let low_time = NotificationService::get_next_allowed_time(&NotificationPriority::Low, 0); + let normal_time = + NotificationService::get_next_allowed_time(&NotificationPriority::Normal, 0); + let high_time = NotificationService::get_next_allowed_time(&NotificationPriority::High, 0); let critical_time = - NotificationService::get_next_allowed_time(&NotificationPriority::Critical); + NotificationService::get_next_allowed_time(&NotificationPriority::Critical, 0); - // Critical should have the shortest wait time, Low should have the longest assert!(critical_time < high_time); assert!(high_time < normal_time); assert!(normal_time < low_time); diff --git a/crates/temps-notifications/src/vulnerability_notifications.rs b/crates/temps-notifications/src/vulnerability_notifications.rs index fca6f3d5a..225ee38d7 100644 --- a/crates/temps-notifications/src/vulnerability_notifications.rs +++ b/crates/temps-notifications/src/vulnerability_notifications.rs @@ -390,6 +390,8 @@ mod tests { is_public_repo: false, git_url: None, git_provider_connection_id: None, + gitlab_webhook_id: None, + gitlab_webhook_signing_token: None, attack_mode: false, source_type: temps_entities::source_type::SourceType::Git, }; diff --git a/crates/temps-projects/src/handlers/handlers.rs b/crates/temps-projects/src/handlers/handlers.rs index 98995ae2f..d26365d82 100644 --- a/crates/temps-projects/src/handlers/handlers.rs +++ b/crates/temps-projects/src/handlers/handlers.rs @@ -21,9 +21,9 @@ use tracing::{debug, error, info}; use super::types::{ CreateProjectRequest, PaginatedProjectList, PaginationParams, ProjectResponse, - ProjectStatisticsResponse, TriggerPipelinePayload, TriggerPipelineResponse, - UpdateAutomaticDeployRequest, UpdateDeploymentConfigRequest, UpdateGitSettingsRequest, - UpdateProjectSettingsRequest, + ProjectStatisticsResponse, ReinstallWebhookResponse, TriggerPipelinePayload, + TriggerPipelineResponse, UpdateAutomaticDeployRequest, UpdateDeploymentConfigRequest, + UpdateGitSettingsRequest, UpdateProjectSettingsRequest, }; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use temps_core::problemdetails; @@ -75,6 +75,10 @@ pub fn configure_routes() -> Router> { "/projects/{project_id}/deployment-config", patch(update_project_deployment_config), ) + .route( + "/projects/{project_id}/gitlab/reinstall-webhook", + post(reinstall_gitlab_webhook), + ) // Merge custom domain routes .merge(custom_domain_routes) } @@ -92,6 +96,7 @@ pub fn configure_routes() -> Router> { update_git_settings, update_automatic_deploy, update_project_deployment_config, + reinstall_gitlab_webhook, trigger_project_pipeline, get_project_statistics, list_presets, @@ -111,6 +116,7 @@ pub fn configure_routes() -> Router> { UpdateGitSettingsRequest, UpdateAutomaticDeployRequest, UpdateDeploymentConfigRequest, + ReinstallWebhookResponse, TriggerPipelinePayload, TriggerPipelineResponse, ProjectStatisticsResponse, @@ -686,6 +692,82 @@ pub async fn update_git_settings( Ok(Json(ProjectResponse::map_from_project(updated_project))) } +/// Reinstall the GitLab webhook for a project +/// +/// Removes the existing webhook (if any) and installs a fresh one. +/// Use this when a webhook has been manually deleted on the GitLab side +/// and automatic deployments have stopped working. +#[utoipa::path( + post, + path = "/projects/{project_id}/gitlab/reinstall-webhook", + tag = "Projects", + responses( + (status = 200, description = "Webhook reinstalled", body = ReinstallWebhookResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Project not found"), + (status = 400, description = "Project is not connected to a GitLab repository"), + (status = 500, description = "Internal server error") + ), + params( + ("project_id" = i32, Path, description = "Project ID") + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn reinstall_gitlab_webhook( + State(state): State>, + Path(project_id): Path, + RequireAuth(auth): RequireAuth, + Extension(metadata): Extension, +) -> Result { + permission_guard!(auth, ProjectsWrite); + + info!("Reinstalling GitLab webhook for project: {}", project_id); + + let hook_id = state + .project_service + .reinstall_gitlab_webhook(project_id) + .await + .map_err(|e| { + error!("Error reinstalling GitLab webhook: {:?}", e); + Problem::from(e) + })?; + + // Audit log the reinstall. + let audit_context = AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.to_string()), + user_agent: metadata.user_agent, + }; + + let audit_event = ProjectUpdatedAudit { + context: audit_context, + project_id, + project_name: format!("project-{}", project_id), + project_slug: String::new(), + updated_fields: ProjectUpdatedFields { + name: None, + repo_name: None, + repo_owner: None, + directory: None, + main_branch: None, + preset: None, + automatic_deploy: None, + }, + }; + + if let Err(e) = state.audit_service.create_audit_log(&audit_event).await { + error!("Failed to create audit log: {:?}", e); + } + + Ok(Json(ReinstallWebhookResponse { + hook_id, + message: "GitLab webhook reinstalled successfully".to_string(), + })) +} + /// Update deployment configuration for a project #[utoipa::path( patch, diff --git a/crates/temps-projects/src/handlers/types.rs b/crates/temps-projects/src/handlers/types.rs index 9b8e208f5..14fd5545d 100644 --- a/crates/temps-projects/src/handlers/types.rs +++ b/crates/temps-projects/src/handlers/types.rs @@ -269,6 +269,12 @@ pub struct ProjectResponse { pub enable_preview_environments: bool, /// Source type for deployments (git, docker_image, or static_files) pub source_type: SourceType, + /// GitLab webhook ID installed on the connected repository. + /// `null` when no GitLab webhook is installed (not connected to GitLab, + /// or webhook was removed / never created). + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 42)] + pub gitlab_webhook_id: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -298,6 +304,7 @@ impl ProjectResponse { attack_mode: project.attack_mode, enable_preview_environments: project.enable_preview_environments, source_type: project.source_type, + gitlab_webhook_id: project.gitlab_webhook_id, deployment_config: DeploymentConfig { cpu_request: project .deployment_config @@ -934,3 +941,12 @@ pub struct GenerateDockerfileResponse { /// The preset slug used for generation pub preset: String, } + +/// Response for `POST /projects/{project_id}/gitlab/reinstall-webhook` +#[derive(Serialize, ToSchema)] +pub struct ReinstallWebhookResponse { + /// The new GitLab hook ID that was installed. + pub hook_id: i32, + /// Human-readable status message. + pub message: String, +} diff --git a/crates/temps-projects/src/services/project.rs b/crates/temps-projects/src/services/project.rs index b9386521b..2df999d0c 100644 --- a/crates/temps-projects/src/services/project.rs +++ b/crates/temps-projects/src/services/project.rs @@ -78,6 +78,7 @@ pub struct ProjectService { pub git_provider_manager: Arc, env_var_service: Arc, environment_service: Arc, + encryption_service: Arc, } impl ProjectService { @@ -90,7 +91,7 @@ impl ProjectService { environment_service: Arc, encryption_service: Arc, ) -> Self { - let env_var_service = Arc::new(EnvVarService::new(db.clone(), encryption_service)); + let env_var_service = Arc::new(EnvVarService::new(db.clone(), encryption_service.clone())); ProjectService { db: db.clone(), @@ -100,6 +101,7 @@ impl ProjectService { git_provider_manager, env_var_service, environment_service, + encryption_service, } } @@ -304,6 +306,53 @@ impl ProjectService { ); } + // Auto-install GitLab webhook if applicable (best-effort, non-fatal). + let project_found_db = if let Some(conn_id) = project_found_db.git_provider_connection_id { + let repo_owner = project_found_db.repo_owner.clone(); + let repo_name = project_found_db.repo_name.clone(); + if !repo_owner.is_empty() && !repo_name.is_empty() { + match self + .install_gitlab_webhook_for_connection( + project_found_db.id, + conn_id, + &repo_owner, + &repo_name, + ) + .await + { + Ok((hook_id, encrypted_token)) => { + let mut active = projects::ActiveModel::from(project_found_db.clone()); + active.gitlab_webhook_id = Set(Some(hook_id as i32)); + active.gitlab_webhook_signing_token = Set(Some(encrypted_token)); + active.updated_at = Set(chrono::Utc::now()); + match active.update(self.db.as_ref()).await { + Ok(updated) => updated, + Err(e) => { + tracing::warn!( + "Failed to persist GitLab webhook fields on new project {}: {}", + project_found_db.id, + e + ); + project_found_db + } + } + } + Err(e) => { + tracing::warn!( + "Failed to install GitLab webhook for new project {}: {}", + project_found_db.id, + e + ); + project_found_db + } + } + } else { + project_found_db + } + } else { + project_found_db + }; + Ok(Self::map_db_project_to_project(project_found_db)) } @@ -883,7 +932,7 @@ impl ProjectService { git_url: Option, is_public_repo: Option, ) -> Result { - // Get the current project + // Get the current project (includes the old gitlab_webhook_id / signing_token) let project = projects::Entity::find_by_id(project_id) .one(self.db.as_ref()) .await? @@ -892,6 +941,12 @@ impl ProjectService { project_id )))?; + // Snapshot fields we need to reason about the old/new repo transition. + let old_connection_id = project.git_provider_connection_id; + let old_repo_owner = project.repo_owner.clone(); + let old_repo_name = project.repo_name.clone(); + let old_gitlab_webhook_id = project.gitlab_webhook_id; + // Verify git provider connection if provided if let Some(connection_id) = git_provider_connection_id { if connection_id > 0 { @@ -935,9 +990,9 @@ impl ProjectService { // Update the project let mut active_project: projects::ActiveModel = project.into(); - active_project.main_branch = Set(main_branch); - active_project.repo_owner = Set(repo_owner); - active_project.repo_name = Set(repo_name); + active_project.main_branch = Set(main_branch.clone()); + active_project.repo_owner = Set(repo_owner.clone()); + active_project.repo_name = Set(repo_name.clone()); active_project.directory = Set(directory); if let Some(preset_value) = preset { @@ -948,13 +1003,26 @@ impl ProjectService { active_project.preset = Set(preset_enum); } - if let Some(connection_id) = git_provider_connection_id { - if connection_id > 0 { - active_project.git_provider_connection_id = Set(Some(connection_id)); - } else { + // Determine the effective new connection id and whether we need to handle + // webhook lifecycle. Three cases: + // 1. connection_id provided and > 0 → connecting / changing repo + // 2. connection_id provided and == 0 → disconnecting + // 3. connection_id not provided → no change + let new_connection_id: Option = match git_provider_connection_id { + Some(cid) if cid > 0 => { + active_project.git_provider_connection_id = Set(Some(cid)); + Some(cid) + } + Some(_) => { + // Explicit disconnect (connection_id == 0) active_project.git_provider_connection_id = Set(None); + None } - } + None => { + // No change requested — carry existing connection forward. + old_connection_id + } + }; if let Some(url) = git_url { active_project.git_url = Set(Some(url)); @@ -982,11 +1050,288 @@ impl ProjectService { active_project.preset_config = Set(Some(parsed_config)); } + // ── GitLab webhook lifecycle ────────────────────────────────────────── + // + // We detect a "repo change" when either the connection or the repo path + // differs from what was previously stored. A change triggers: + // • Delete the old webhook from GitLab (best-effort, idempotent on 404). + // • Install a new webhook on the new repo (GitLab connections only). + // + // Failures here are non-fatal: we log warnings and continue so that the + // project save always succeeds. + + let repo_changed = git_provider_connection_id.is_some() + || repo_owner != old_repo_owner + || repo_name != old_repo_name; + + if repo_changed { + // Step 1: Remove old webhook if the old connection was GitLab. + if let (Some(old_hook_id), Some(old_conn_id)) = + (old_gitlab_webhook_id, old_connection_id) + { + if let Err(e) = self + .delete_gitlab_webhook_for_connection( + old_conn_id, + &old_repo_owner, + &old_repo_name, + old_hook_id, + ) + .await + { + warn!( + "Failed to remove old GitLab webhook {} for project {}: {}", + old_hook_id, project_id, e + ); + } + // Clear stale hook fields unconditionally — even if delete failed + // (it may already be gone on GitLab's side). + active_project.gitlab_webhook_id = Set(None); + active_project.gitlab_webhook_signing_token = Set(None); + } + + // Step 2: Install a new webhook if the new connection is GitLab. + if let Some(conn_id) = new_connection_id { + match self + .install_gitlab_webhook_for_connection( + project_id, + conn_id, + &repo_owner, + &repo_name, + ) + .await + { + Ok((hook_id, encrypted_token)) => { + active_project.gitlab_webhook_id = Set(Some(hook_id as i32)); + active_project.gitlab_webhook_signing_token = Set(Some(encrypted_token)); + } + Err(e) => { + // Non-fatal: the project connects without the webhook. + warn!( + "Failed to install GitLab webhook for project {}: {}", + project_id, e + ); + active_project.gitlab_webhook_id = Set(None); + active_project.gitlab_webhook_signing_token = Set(None); + } + } + } + } else if git_provider_connection_id == Some(0) { + // Explicit disconnect: clear webhook state. + active_project.gitlab_webhook_id = Set(None); + active_project.gitlab_webhook_signing_token = Set(None); + } + let updated_project = active_project.update(self.db.as_ref()).await?; Ok(Self::map_db_project_to_project(updated_project)) } + /// Resolve whether the given connection points to a GitLab provider. + /// Returns `(base_url, access_token, auth_method)` for GitLab connections; + /// `Err` for all others. + async fn resolve_gitlab_connection( + &self, + connection_id: i32, + ) -> Result<(String, String, String), String> { + use temps_entities::{git_provider_connections, git_providers}; + + let connection = git_provider_connections::Entity::find_by_id(connection_id) + .one(self.db.as_ref()) + .await + .map_err(|e| format!("DB error: {}", e))? + .ok_or_else(|| format!("Connection {} not found", connection_id))?; + + let provider = git_providers::Entity::find_by_id(connection.provider_id) + .one(self.db.as_ref()) + .await + .map_err(|e| format!("DB error: {}", e))? + .ok_or_else(|| format!("Provider {} not found", connection.provider_id))?; + + // Only handle GitLab providers. + if provider.provider_type != "gitlab" { + return Err(format!( + "Provider {} is not a GitLab provider (type: {})", + provider.id, provider.provider_type + )); + } + + let base_url = provider + .base_url + .unwrap_or_else(|| "https://gitlab.com".to_string()); + + let auth_method = provider.auth_method.clone(); + + let access_token = self + .git_provider_manager + .get_connection_token(connection_id) + .await + .map_err(|e| { + format!( + "Failed to get access token for connection {}: {}", + connection_id, e + ) + })?; + + Ok((base_url, access_token, auth_method)) + } + + /// Install a GitLab webhook for the given project/connection. + /// Returns `(hook_id, encrypted_signing_token)` on success. + async fn install_gitlab_webhook_for_connection( + &self, + project_id: i32, + connection_id: i32, + owner: &str, + repo: &str, + ) -> Result<(i64, String), String> { + use temps_git::services::gitlab_webhook::{ + generate_signing_token, GitLabWebhookClient, WebhookAuthMethod, + }; + + let (base_url, access_token, auth_method_str) = + match self.resolve_gitlab_connection(connection_id).await { + Ok(triple) => triple, + // Not a GitLab provider — skip silently. + Err(e) => return Err(e), + }; + + let client = GitLabWebhookClient::new( + base_url, + access_token, + WebhookAuthMethod::from_str(&auth_method_str), + ); + + // Pre-flight: verify the user has >= Maintainer (40) access. + let access_level = client + .get_project_access_level(owner, repo) + .await + .map_err(|e| format!("Could not check permissions for {}/{}: {}", owner, repo, e))?; + + if access_level < 40 { + return Err(format!( + "Insufficient GitLab permissions for {}/{}: access_level={} (need >= 40 Maintainer)", + owner, repo, access_level + )); + } + + // Resolve the webhook URL from config. + let external_url = self + .config_service + .get_settings() + .await + .ok() + .and_then(|s| s.external_url) + .unwrap_or_else(|| "http://localhost:8080".to_string()); + let webhook_url = format!("{}/api/webhook/git/gitlab/events", external_url); + + // Generate a random 32-byte signing token. + let signing_token = generate_signing_token(); + + let hook_id = client + .install_webhook(owner, repo, &webhook_url, &signing_token) + .await + .map_err(|e| { + format!( + "Failed to install webhook for project {}: {}", + project_id, e + ) + })?; + + // Encrypt the token before storing. + let encrypted = self + .encryption_service + .encrypt_string(&signing_token) + .map_err(|e| format!("Failed to encrypt signing token: {}", e))?; + + info!( + "Installed GitLab webhook {} for project {} ({}/{})", + hook_id, project_id, owner, repo + ); + + Ok((hook_id, encrypted)) + } + + /// Remove a GitLab webhook. Best-effort; 404 is treated as success. + async fn delete_gitlab_webhook_for_connection( + &self, + connection_id: i32, + owner: &str, + repo: &str, + hook_id: i32, + ) -> Result<(), String> { + use temps_git::services::gitlab_webhook::{GitLabWebhookClient, WebhookAuthMethod}; + + let (base_url, access_token, auth_method_str) = + match self.resolve_gitlab_connection(connection_id).await { + Ok(triple) => triple, + // Not a GitLab connection — nothing to remove. + Err(_) => return Ok(()), + }; + + let client = GitLabWebhookClient::new( + base_url, + access_token, + WebhookAuthMethod::from_str(&auth_method_str), + ); + client + .delete_webhook(owner, repo, hook_id as i64) + .await + .map_err(|e| format!("GitLab delete webhook error: {}", e)) + } + + /// Reinstall (or install for the first time) a GitLab webhook for a project. + /// Called by `POST /projects/{id}/gitlab/reinstall-webhook`. + pub async fn reinstall_gitlab_webhook(&self, project_id: i32) -> Result { + let project = projects::Entity::find_by_id(project_id) + .one(self.db.as_ref()) + .await? + .ok_or(ProjectError::NotFound(format!( + "Project {} not found", + project_id + )))?; + + let connection_id = project.git_provider_connection_id.ok_or_else(|| { + ProjectError::Other(format!( + "Project {} has no git provider connection", + project_id + )) + })?; + + let owner = project.repo_owner.clone(); + let repo = project.repo_name.clone(); + + // Best-effort: remove the old webhook first. + if let Some(old_hook_id) = project.gitlab_webhook_id { + if let Err(e) = self + .delete_gitlab_webhook_for_connection(connection_id, &owner, &repo, old_hook_id) + .await + { + warn!( + "Failed to remove old GitLab webhook {} during reinstall for project {}: {}", + old_hook_id, project_id, e + ); + } + } + + let (hook_id, encrypted_token) = self + .install_gitlab_webhook_for_connection(project_id, connection_id, &owner, &repo) + .await + .map_err(ProjectError::Other)?; + + // Persist the new hook id + token. + let mut active_project: projects::ActiveModel = project.into(); + active_project.gitlab_webhook_id = Set(Some(hook_id as i32)); + active_project.gitlab_webhook_signing_token = Set(Some(encrypted_token)); + active_project.update(self.db.as_ref()).await?; + + info!( + "Reinstalled GitLab webhook {} for project {}", + hook_id, project_id + ); + + Ok(hook_id as i32) + } + pub async fn get_projects_paginated( &self, page: i64, @@ -1304,6 +1649,7 @@ impl ProjectService { attack_mode: db_project.attack_mode, enable_preview_environments: db_project.enable_preview_environments, source_type: db_project.source_type, + gitlab_webhook_id: db_project.gitlab_webhook_id, } } diff --git a/crates/temps-projects/src/services/types.rs b/crates/temps-projects/src/services/types.rs index 69d0b1a08..63c7be739 100644 --- a/crates/temps-projects/src/services/types.rs +++ b/crates/temps-projects/src/services/types.rs @@ -66,6 +66,8 @@ pub struct Project { pub enable_preview_environments: bool, /// Source type for deployments (git, docker_image, or static_files) pub source_type: SourceType, + /// GitLab webhook ID installed on the connected repository, if any. + pub gitlab_webhook_id: Option, } #[derive(Deserialize)] diff --git a/crates/temps-providers/Cargo.toml b/crates/temps-providers/Cargo.toml index 5a217301b..512617b11 100644 --- a/crates/temps-providers/Cargo.toml +++ b/crates/temps-providers/Cargo.toml @@ -51,6 +51,7 @@ axum-macros = { workspace = true } schemars = { workspace = true } urlencoding = "2.1" tokio-postgres = "0.7" +base64 = { workspace = true } [features] docker-tests = [] diff --git a/crates/temps-providers/src/externalsvc/mongodb.rs b/crates/temps-providers/src/externalsvc/mongodb.rs index 3ad0ab2c0..e2bda962e 100644 --- a/crates/temps-providers/src/externalsvc/mongodb.rs +++ b/crates/temps-providers/src/externalsvc/mongodb.rs @@ -59,6 +59,15 @@ pub struct MongodbInputConfig { #[serde(default = "default_docker_image")] #[schemars(example = "example_docker_image", default = "default_docker_image")] pub docker_image: String, + + /// Optional replica set name. When set, mongod is started with `--replSet `, + /// a keyfile-protected `--auth`, and `rs.initiate()` is run after first start. + /// Required for transactions, change streams, and oplog-based CDC. + /// This is a single-node replica set — for multi-node HA use the cluster topology. + /// Cannot be changed after creation: switching modes on an existing data volume corrupts state. + #[serde(default, deserialize_with = "deserialize_optional_replica_set")] + #[schemars(with = "Option", example = "example_replica_set")] + pub replica_set: Option, } // Example functions for schemars @@ -90,6 +99,10 @@ fn example_docker_image() -> &'static str { "gotempsh/mongodb-walg:8.0" } +fn example_replica_set() -> &'static str { + "rs0" +} + /// Internal runtime configuration for MongoDB service /// This is what the service uses internally after processing input /// and what gets saved to the database @@ -101,10 +114,25 @@ pub struct MongodbRuntimeConfig { pub username: String, pub password: String, pub docker_image: String, + /// When set, mongod runs with `--replSet `. None means standalone mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replica_set: Option, + /// Base64 keyfile contents used for `--keyFile` when replica_set is enabled. + /// MongoDB requires a keyfile whenever both `--auth` and `--replSet` are set, + /// even for a single-node replica set. Generated once at creation and persisted + /// here so the same keyfile is written into the container on every restart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyfile_content: Option, } impl From for MongodbRuntimeConfig { fn from(input: MongodbInputConfig) -> Self { + let replica_set = input.replica_set; + let keyfile_content = if replica_set.is_some() { + Some(generate_keyfile_content()) + } else { + None + }; Self { host: input.host, port: input.port.unwrap_or_else(|| { @@ -116,6 +144,8 @@ impl From for MongodbRuntimeConfig { username: input.username, password: input.password.unwrap_or_else(generate_password), docker_image: input.docker_image, + replica_set, + keyfile_content, } } } @@ -134,6 +164,28 @@ where }) } +/// Treat empty string as `None` so the UI can submit a blank field without +/// accidentally enabling replica set mode. Validates the name contains only +/// the characters MongoDB accepts in a replica set name. +fn deserialize_optional_replica_set<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + let trimmed = opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + if let Some(ref name) = trimmed { + let valid = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if !valid { + return Err(serde::de::Error::custom( + "replica_set must contain only ASCII letters, digits, '-', or '_'", + )); + } + } + Ok(trimmed) +} + fn default_host() -> String { "localhost".to_string() } @@ -155,6 +207,17 @@ pub fn generate_password() -> String { .collect() } +/// Generate a MongoDB keyfile body. Mongo accepts a base64-encoded shared secret +/// between 6 and 1024 characters; we use 32 random bytes (~44 chars base64) which +/// matches what `openssl rand -base64 32` produces in MongoDB's own docs. +pub fn generate_keyfile_content() -> String { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + STANDARD.encode(bytes) +} + fn is_port_available(port: u16) -> bool { TcpListener::bind(("0.0.0.0", port)).is_ok() } @@ -178,12 +241,70 @@ impl MongodbService { } } + /// Returns `true` when the desired config wants a replica set but the + /// existing container was created without `--replSet`. In that case the + /// caller must remove and recreate the container (preserving the data + /// volume) so the new flags take effect; a plain `start_container` would + /// just bring up the previous standalone process. + /// + /// Returns `false` (and never recreates) when the container already runs + /// in replica-set mode, or when the config is standalone — downgrading + /// from replica set back to standalone is intentionally not supported. + async fn container_needs_replset_recreate( + &self, + docker: &Docker, + container_name: &str, + config: &MongodbRuntimeConfig, + ) -> Result { + if config.replica_set.is_none() { + return Ok(false); + } + let inspect = docker + .inspect_container(container_name, None::) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to inspect MongoDB container '{}' for replica-set drift check: {}", + container_name, + e + ) + })?; + let cmd_has_replset = inspect + .config + .as_ref() + .and_then(|c| c.cmd.as_ref()) + .map(|cmd| cmd.iter().any(|arg| arg.contains("--replSet"))) + .unwrap_or(false); + Ok(!cmd_has_replset) + } + fn get_mongodb_config(&self, service_config: ServiceConfig) -> Result { - // Deserialize input config from parameters + // After init the persisted parameters carry runtime-only fields like + // `keyfile_content`. We must NOT round-trip those through the input + // config — that would drop the keyfile and `From` would + // regenerate a new one, breaking the live replica set's auth. + // + // Detect that case by looking for `keyfile_content`. If present, the + // parameters are already runtime-shaped and we deserialize directly. + if service_config + .parameters + .get("keyfile_content") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_some() + { + let runtime: MongodbRuntimeConfig = serde_json::from_value(service_config.parameters) + .map_err(|e| { + anyhow::anyhow!("Failed to parse MongoDB runtime configuration: {}", e) + })?; + return Ok(runtime); + } + + // First-time init or standalone (no replica set): parse as input + // config and transform. This auto-generates password/port and, if + // replica_set is set, generates a fresh keyfile. let input_config: MongodbInputConfig = serde_json::from_value(service_config.parameters) .map_err(|e| anyhow::anyhow!("Failed to parse MongoDB input configuration: {}", e))?; - - // Transform input config to runtime config (auto-generates password if needed) Ok(input_config.into()) } @@ -207,12 +328,23 @@ impl MongodbService { info!("Created MongoDB volume: {}", volume_name); - let env_vars = [ + let mut env_vars: Vec = vec![ format!("MONGO_INITDB_ROOT_USERNAME={}", config.username), format!("MONGO_INITDB_ROOT_PASSWORD={}", config.password), format!("MONGO_INITDB_DATABASE={}", config.database), ]; + // When replica set mode is enabled, smuggle the keyfile through an env + // var read by a small bash wrapper (see `cmd` below). Persisting the + // keyfile in `MongodbRuntimeConfig` means restarts use the same key, + // which is critical — a different keyfile would invalidate the + // existing replica set's local.system.keys. + if let (Some(_), Some(keyfile_content)) = + (config.replica_set.as_ref(), config.keyfile_content.as_ref()) + { + env_vars.push(format!("TEMPS_MONGO_KEYFILE_B64={}", keyfile_content)); + } + let mut container_labels = HashMap::new(); container_labels.insert("temps.service".to_string(), "mongodb".to_string()); container_labels.insert("temps.name".to_string(), self.name.clone()); @@ -268,10 +400,38 @@ impl MongodbService { )])), }); + // In replica-set mode we replace the image's CMD with a bash wrapper + // that materializes the keyfile inside the container (with strict + // perms required by mongod), then execs the standard entrypoint with + // `--replSet` and `--keyFile`. The official mongo entrypoint still + // handles MONGO_INITDB_ROOT_USERNAME/PASSWORD via the localhost + // exception during first boot. + // + // We deliberately avoid mounting the keyfile from the host: that path + // would require coordinating a host-side temp file across worker + // nodes. Writing it inside the container at start time keeps the + // service self-contained. + let cmd_override: Option> = config.replica_set.as_ref().map(|rs_name| { + let escaped_rs = rs_name.replace('\'', "'\\''"); + let script = format!( + concat!( + "set -e; ", + "printf '%s' \"$TEMPS_MONGO_KEYFILE_B64\" > /etc/mongo-keyfile; ", + "chmod 400 /etc/mongo-keyfile; ", + "chown mongodb:mongodb /etc/mongo-keyfile 2>/dev/null || true; ", + "exec docker-entrypoint.sh mongod ", + "--replSet '{}' --bind_ip_all --keyFile /etc/mongo-keyfile", + ), + escaped_rs + ); + vec!["bash".to_string(), "-c".to_string(), script] + }); + let container_config = bollard::models::ContainerCreateBody { image: Some(image_tag), exposed_ports: Some(Vec::from(["27017/tcp".to_string()])), env: Some(env_vars.iter().map(|s| s.to_string()).collect()), + cmd: cmd_override, labels: Some(container_labels), host_config: Some(bollard::models::HostConfig { restart_policy: Some(bollard::models::RestartPolicy { @@ -325,10 +485,177 @@ impl MongodbService { self.wait_for_container_health(docker, &container.id) .await?; + // Replica set mode: initiate the set after first healthy boot. This + // is idempotent — if it's already initiated (e.g., container restart), + // mongod replies AlreadyInitialized and we treat that as success. + if let Some(rs_name) = config.replica_set.as_ref() { + self.initiate_replica_set(docker, &container_name, config, rs_name) + .await?; + } + info!("MongoDB container {} created and started", container.id); Ok(()) } + /// Run `rs.initiate(...)` inside the container against localhost. Uses the + /// root credentials we know mongod will accept (they were created by the + /// entrypoint during first-boot via the localhost exception). On a + /// container restart the replica set is already initialized — that surfaces + /// as `AlreadyInitialized` (code 23) and we return success. + async fn initiate_replica_set( + &self, + docker: &Docker, + container_name: &str, + config: &MongodbRuntimeConfig, + rs_name: &str, + ) -> Result<()> { + use bollard::exec::{StartExecOptions, StartExecResults}; + + // Pass credentials via env to avoid quoting hazards in the shell + // command. The replica-set name is also injected as an env var so it + // can't break out of the JSON literal. + let env = [ + format!("INIT_USER={}", config.username), + format!("INIT_PASS={}", config.password), + format!("INIT_RS={}", rs_name), + ]; + let env_refs: Vec<&str> = env.iter().map(String::as_str).collect(); + + let script = "mongosh --quiet --norc \ + -u \"$INIT_USER\" -p \"$INIT_PASS\" --authenticationDatabase admin \ + --eval 'try { rs.initiate({_id: process.env.INIT_RS, members: [{_id: 0, host: \"127.0.0.1:27017\"}]}); } catch (e) { if (e.codeName !== \"AlreadyInitialized\" && !String(e).includes(\"already initialized\")) { throw e; } print(\"replica set already initialized\"); }' 2>&1"; + + let exec = docker + .create_exec( + container_name, + CreateExecOptions { + cmd: Some(vec!["sh", "-c", script]), + attach_stdout: Some(true), + attach_stderr: Some(true), + env: Some(env_refs), + ..Default::default() + }, + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to create rs.initiate exec on '{}': {}", + container_name, + e + ) + })?; + + let start = docker + .start_exec( + &exec.id, + Some(StartExecOptions { + detach: false, + ..Default::default() + }), + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to start rs.initiate exec: {}", e))?; + + let mut captured = String::new(); + if let StartExecResults::Attached { mut output, .. } = start { + while let Some(chunk) = output.next().await { + if let Ok(log) = chunk { + captured.push_str(&log.to_string()); + if captured.len() > 4096 { + break; + } + } + } + } + + let inspect = docker.inspect_exec(&exec.id).await?; + let exit_code = inspect.exit_code.unwrap_or(-1); + + if exit_code == 0 { + info!( + "rs.initiate completed on '{}' (replica set '{}')", + container_name, rs_name + ); + // Wait briefly for the node to elect itself primary so subsequent + // operations (e.g. provision_resource creating databases) don't + // race the election. mongod typically reaches PRIMARY in <2s for + // a single-node set; we cap the wait at 30s. + self.wait_for_primary(docker, container_name, config) + .await?; + Ok(()) + } else { + Err(anyhow::anyhow!( + "rs.initiate failed on '{}' with exit {}: {}", + container_name, + exit_code, + captured.trim() + )) + } + } + + /// Poll `db.hello()` until it reports `isWritablePrimary: true` or 30s elapse. + /// Single-node replica sets normally elect themselves primary in <2s. + async fn wait_for_primary( + &self, + docker: &Docker, + container_name: &str, + config: &MongodbRuntimeConfig, + ) -> Result<()> { + use bollard::exec::{StartExecOptions, StartExecResults}; + + let env = [ + format!("INIT_USER={}", config.username), + format!("INIT_PASS={}", config.password), + ]; + let env_refs: Vec<&str> = env.iter().map(String::as_str).collect(); + let probe_script = "mongosh --quiet --norc \ + -u \"$INIT_USER\" -p \"$INIT_PASS\" --authenticationDatabase admin \ + --eval 'const r = db.hello(); if (!r.isWritablePrimary) { quit(2); }' 2>&1"; + + let max_wait = Duration::from_secs(30); + let start = std::time::Instant::now(); + loop { + let exec = docker + .create_exec( + container_name, + CreateExecOptions { + cmd: Some(vec!["sh", "-c", probe_script]), + attach_stdout: Some(true), + attach_stderr: Some(true), + env: Some(env_refs.clone()), + ..Default::default() + }, + ) + .await?; + + if let StartExecResults::Attached { mut output, .. } = docker + .start_exec( + &exec.id, + Some(StartExecOptions { + detach: false, + ..Default::default() + }), + ) + .await? + { + while output.next().await.is_some() {} + } + + let inspect = docker.inspect_exec(&exec.id).await?; + if inspect.exit_code == Some(0) { + return Ok(()); + } + if start.elapsed() > max_wait { + return Err(anyhow::anyhow!( + "Replica set on '{}' did not elect a primary within {}s", + container_name, + max_wait.as_secs() + )); + } + sleep(Duration::from_millis(500)).await; + } + } + async fn wait_for_container_health(&self, docker: &Docker, container_id: &str) -> Result<()> { let mut delay = Duration::from_millis(500); let mut total_wait = Duration::from_secs(0); @@ -373,8 +700,15 @@ impl MongodbService { .ok_or_else(|| anyhow::anyhow!("MongoDB not configured"))? .clone(); + // `directConnection=true` keeps the driver pointed at exactly this + // host:port instead of doing replica-set topology discovery. In RS + // mode `rs.initiate` registers the member as `127.0.0.1:27017`, which + // the driver would then try to dial — that address is the container's + // loopback and is unreachable from the host or from sibling + // containers. Direct connection bypasses discovery and is safe on + // standalone too. See ReplicaSetNoPrimary failure mode. let connection_string = format!( - "mongodb://{}:{}@{}:{}/?authSource=admin", + "mongodb://{}:{}@{}:{}/?authSource=admin&directConnection=true", urlencoding::encode(&config.username), urlencoding::encode(&config.password), config.host, @@ -1154,8 +1488,12 @@ impl ExternalService for MongodbService { }; // authSource=admin because that's where we create the root user. + // directConnection=true skips replica-set topology discovery so the + // probe checks *this* node instead of chasing the member address + // advertised by `rs.initiate` (`127.0.0.1:27017`, unreachable from + // outside the container). Safe on standalone too. let uri = format!( - "mongodb://{}:{}@{}:{}/?authSource=admin&serverSelectionTimeoutMS=3000&connectTimeoutMS=3000", + "mongodb://{}:{}@{}:{}/?authSource=admin&directConnection=true&serverSelectionTimeoutMS=3000&connectTimeoutMS=3000", urlencoding::encode(&cfg.username), urlencoding::encode(&cfg.password), cfg.host, @@ -1252,6 +1590,9 @@ impl ExternalService for MongodbService { "username" => false, // Don't change username after creation "password" => false, // Password is auto-generated and cannot be changed "docker_image" => true, // Docker image can be upgraded + // One-way: standalone -> replica set is supported in-place. + // The merge_updates strategy rejects unsetting or renaming. + "replica_set" => true, _ => false, }; @@ -1280,24 +1621,70 @@ impl ExternalService for MongodbService { })) .await?; + let config = self + .config + .read() + .await + .as_ref() + .ok_or_else(|| anyhow::anyhow!("MongoDB configuration not found"))? + .clone(); + if containers.is_empty() { - let config = self - .config - .read() - .await - .as_ref() - .ok_or_else(|| anyhow::anyhow!("MongoDB configuration not found"))? - .clone(); self.create_container(docker, &config).await?; } else { - docker - .start_container( - &container_name, - None::, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to start MongoDB container: {}", e))?; - info!("Started existing MongoDB container: {}", container_name); + // If the persisted config now requires `--replSet` but the + // existing container was created in standalone mode, restarting it + // would just bring up the old standalone again. Detect drift by + // inspecting the container's Cmd, then recreate (preserving the + // data volume — `remove_container` does NOT touch named volumes). + if self + .container_needs_replset_recreate(docker, &container_name, &config) + .await? + { + info!( + "MongoDB container {} needs recreate to apply replica_set='{}'; \ + removing standalone container and recreating in replica-set mode \ + (data volume preserved)", + container_name, + config.replica_set.as_deref().unwrap_or("") + ); + let _ = docker + .stop_container( + &container_name, + Some(StopContainerOptions { + t: Some(10), + signal: None, + }), + ) + .await; + docker + .remove_container( + &container_name, + Some(bollard::query_parameters::RemoveContainerOptions { + v: false, // keep the data volume + force: true, + ..Default::default() + }), + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to remove standalone MongoDB container before \ + replica-set recreate: {}", + e + ) + })?; + self.create_container(docker, &config).await?; + } else { + docker + .start_container( + &container_name, + None::, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to start MongoDB container: {}", e))?; + info!("Started existing MongoDB container: {}", container_name); + } } Ok(()) @@ -2057,6 +2444,7 @@ mod tests { "username", "password", "docker_image", + "replica_set", ]; for field in &expected_fields { assert!( @@ -2110,6 +2498,7 @@ mod tests { ("username", false), ("password", false), ("docker_image", true), + ("replica_set", true), ]; for (field_name, should_be_editable) in editable_status { @@ -2316,6 +2705,118 @@ mod tests { ); } + #[test] + fn test_replica_set_default_is_none() { + let input: MongodbInputConfig = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "admin", + "username": "root", + "docker_image": "gotempsh/mongodb-walg:8.0", + })) + .expect("should deserialize without replica_set"); + assert!(input.replica_set.is_none()); + + let runtime: MongodbRuntimeConfig = input.into(); + assert!(runtime.replica_set.is_none()); + assert!(runtime.keyfile_content.is_none()); + } + + #[test] + fn test_replica_set_some_generates_keyfile() { + let input: MongodbInputConfig = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "admin", + "username": "root", + "docker_image": "gotempsh/mongodb-walg:8.0", + "replica_set": "rs0", + })) + .expect("should deserialize with replica_set"); + assert_eq!(input.replica_set.as_deref(), Some("rs0")); + + let runtime: MongodbRuntimeConfig = input.into(); + assert_eq!(runtime.replica_set.as_deref(), Some("rs0")); + let kf = runtime + .keyfile_content + .expect("keyfile should be generated"); + // Base64 of 32 bytes is 44 chars including padding + assert_eq!(kf.len(), 44); + } + + #[test] + fn test_replica_set_empty_string_treated_as_none() { + let input: MongodbInputConfig = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "admin", + "username": "root", + "docker_image": "gotempsh/mongodb-walg:8.0", + "replica_set": "", + })) + .expect("empty replica_set should deserialize"); + assert!(input.replica_set.is_none()); + } + + #[test] + fn test_replica_set_rejects_invalid_chars() { + let result: Result = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "admin", + "username": "root", + "docker_image": "gotempsh/mongodb-walg:8.0", + "replica_set": "bad name with spaces", + })); + assert!(result.is_err(), "spaces in replica_set should fail"); + } + + #[test] + fn test_runtime_config_round_trip_preserves_keyfile() { + // First-time init: input has replica_set, no keyfile + let input: MongodbInputConfig = serde_json::from_value(serde_json::json!({ + "host": "localhost", + "database": "admin", + "username": "root", + "password": "secret", + "docker_image": "gotempsh/mongodb-walg:8.0", + "replica_set": "rs0", + })) + .unwrap(); + let runtime: MongodbRuntimeConfig = input.into(); + let original_keyfile = runtime.keyfile_content.clone().unwrap(); + + // Persisted as JSON, then loaded back via the runtime path + let persisted = serde_json::to_value(&runtime).unwrap(); + assert!(persisted.get("keyfile_content").is_some()); + + let docker = match Docker::connect_with_local_defaults() { + Ok(d) => Arc::new(d), + Err(_) => return, // No docker on host; skip this round-trip path + }; + let service = MongodbService::new("test-rt".to_string(), docker); + let svc_config = ServiceConfig { + name: "test-rt".into(), + service_type: ServiceType::Mongodb, + version: None, + parameters: persisted, + }; + let reloaded = service + .get_mongodb_config(svc_config) + .expect("should reload runtime config"); + // Critical: the keyfile must NOT be regenerated on reload + assert_eq!( + reloaded.keyfile_content.as_deref(), + Some(original_keyfile.as_str()) + ); + assert_eq!(reloaded.replica_set.as_deref(), Some("rs0")); + } + + #[test] + fn test_generate_keyfile_content_is_random() { + let a = generate_keyfile_content(); + let b = generate_keyfile_content(); + assert_eq!(a.len(), 44); + assert_eq!(b.len(), 44); + assert_ne!(a, b); + } + /// Test backup and restore of MongoDB to/from S3 using real Docker containers /// This test uses MongoDB and MinIO (S3-compatible) containers /// Demonstrates the use of test_utils for backup/restore testing @@ -2385,6 +2886,8 @@ mod tests { username: username.to_string(), password: password.to_string(), docker_image: "gotempsh/mongodb-walg:8.0".to_string(), + replica_set: None, + keyfile_content: None, }; *service.config.write().await = Some(mongodb_config.clone()); diff --git a/crates/temps-providers/src/parameter_strategies.rs b/crates/temps-providers/src/parameter_strategies.rs index 97d38d8dc..74fe37c0a 100644 --- a/crates/temps-providers/src/parameter_strategies.rs +++ b/crates/temps-providers/src/parameter_strategies.rs @@ -850,7 +850,7 @@ impl ParameterStrategy for MongodbParameterStrategy { } fn updateable_keys(&self) -> Vec<&'static str> { - vec!["port", "docker_image"] + vec!["port", "docker_image", "replica_set"] } fn readonly_keys(&self) -> Vec<&'static str> { @@ -864,6 +864,24 @@ impl ParameterStrategy for MongodbParameterStrategy { ) -> Result<(), String> { self.validate_for_update(&updates)?; + // replica_set is one-way: a standalone (None or empty) can be promoted + // to a replica set, but unsetting or renaming an existing one would + // strand the keyfile and orphan the local.system.replset config. + if let Some(new_rs) = updates.get("replica_set") { + let new_name = new_rs.as_str().unwrap_or("").trim(); + let existing_name = existing + .get("replica_set") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if !existing_name.is_empty() && new_name != existing_name { + return Err(format!( + "Cannot change 'replica_set' from '{}' to '{}': renaming or unsetting an existing replica set is not supported", + existing_name, new_name + )); + } + } + for (key, value) in updates { existing.insert(key, value); } diff --git a/crates/temps-proxy/src/on_demand.rs b/crates/temps-proxy/src/on_demand.rs index f829652cb..d7cc5f8db 100644 --- a/crates/temps-proxy/src/on_demand.rs +++ b/crates/temps-proxy/src/on_demand.rs @@ -1225,6 +1225,13 @@ mod tests { ready_at: None, deleted_at: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }]]) // NOTIFY .append_exec_results(vec![MockExecResult { @@ -1292,6 +1299,13 @@ mod tests { ready_at: None, deleted_at: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }]]) // Revert UPDATE sleeping=true .append_exec_results(vec![MockExecResult { @@ -1432,6 +1446,13 @@ mod tests { ready_at: None, deleted_at: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, }, deployment_containers::Model { id: 2, @@ -1447,7 +1468,14 @@ mod tests { deployed_at: chrono::Utc::now(), ready_at: None, deleted_at: None, - node_id: Some(2), // Remote node + node_id: Some(2), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, // Remote node }, deployment_containers::Model { id: 3, @@ -1463,7 +1491,14 @@ mod tests { deployed_at: chrono::Utc::now(), ready_at: None, deleted_at: None, - node_id: Some(3), // Another remote node + node_id: Some(3), + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, // Another remote node }, ]]) // NOTIFY @@ -1786,6 +1821,13 @@ mod tests { status: Some("stopped".to_string()), service_name: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, created_at: chrono::Utc::now(), deployed_at: chrono::Utc::now(), ready_at: None, @@ -1883,6 +1925,13 @@ mod tests { status: Some("running".to_string()), service_name: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, created_at: chrono::Utc::now(), deployed_at: chrono::Utc::now(), ready_at: None, @@ -1899,6 +1948,13 @@ mod tests { status: Some("running".to_string()), service_name: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, created_at: chrono::Utc::now(), deployed_at: chrono::Utc::now(), ready_at: None, @@ -1985,6 +2041,13 @@ mod tests { status: Some("running".to_string()), service_name: None, node_id: None, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, created_at: chrono::Utc::now(), deployed_at: chrono::Utc::now(), ready_at: None, @@ -2088,6 +2151,13 @@ mod tests { ready_at: None, deleted_at: None, node_id, + exit_code: None, + exit_reason: None, + oom_killed: None, + error_message: None, + finished_at: None, + started_at: None, + cpu_limit_cores: None, } } diff --git a/crates/temps-proxy/src/proxy.rs b/crates/temps-proxy/src/proxy.rs index a43da872c..604d871ac 100644 --- a/crates/temps-proxy/src/proxy.rs +++ b/crates/temps-proxy/src/proxy.rs @@ -1104,66 +1104,56 @@ impl LoadBalancer { .and_then(|h| h.get("x-cache").or_else(|| h.get("cf-cache-status"))) .cloned(); - // Only log HTML pages (skip static assets like .js, .css, .svg, etc.) - let should_log = ctx - .response_headers - .as_ref() - .and_then(|h| h.get("content-type")) - .map(|ct| ct.starts_with("text/html")) - .unwrap_or(false); - - if should_log { - let proxy_log_request = CreateProxyLogRequest { - method: ctx.method.clone(), - path: ctx.path.clone(), - query_string: ctx.query_string.clone(), - host: ctx.host.clone(), - status_code: status_code as i16, - response_time_ms: Some(ctx.start_time.elapsed().as_millis() as i32), - request_source: "proxy".to_string(), - is_system_request: ctx.path.starts_with(ROUTE_PREFIX_TEMPS), - routing_status: ctx.routing_status.clone(), - project_id: ctx.project.as_ref().map(|p| p.id), - environment_id: ctx.environment.as_ref().map(|e| e.id), - deployment_id: ctx.deployment.as_ref().map(|d| d.id), - session_id: ctx.session_id_i32, - visitor_id: ctx.visitor_id_i32, - container_id: ctx.container_id.clone(), - upstream_host: ctx.upstream_host.clone(), - error_message: ctx.error_message.clone(), - client_ip: ctx.ip_address.clone(), - user_agent: Some(ctx.user_agent.clone()), - referrer: ctx.referrer.clone(), - request_id: ctx.request_id.clone(), - // Batch writer will enrich these fields - ip_geolocation_id: None, - browser: None, - browser_version: None, - operating_system: None, - device_type: None, - is_bot: None, - bot_name: None, - request_size_bytes: request_size, - response_size_bytes: response_size, - cache_status, - request_headers: ctx - .request_headers - .as_ref() - .and_then(|h| serde_json::to_value(h).ok()), - response_headers: ctx - .response_headers - .as_ref() - .and_then(|h| serde_json::to_value(h).ok()), - }; + let proxy_log_request = CreateProxyLogRequest { + method: ctx.method.clone(), + path: ctx.path.clone(), + query_string: ctx.query_string.clone(), + host: ctx.host.clone(), + status_code: status_code as i16, + response_time_ms: Some(ctx.start_time.elapsed().as_millis() as i32), + request_source: "proxy".to_string(), + is_system_request: ctx.path.starts_with(ROUTE_PREFIX_TEMPS), + routing_status: ctx.routing_status.clone(), + project_id: ctx.project.as_ref().map(|p| p.id), + environment_id: ctx.environment.as_ref().map(|e| e.id), + deployment_id: ctx.deployment.as_ref().map(|d| d.id), + session_id: ctx.session_id_i32, + visitor_id: ctx.visitor_id_i32, + container_id: ctx.container_id.clone(), + upstream_host: ctx.upstream_host.clone(), + error_message: ctx.error_message.clone(), + client_ip: ctx.ip_address.clone(), + user_agent: Some(ctx.user_agent.clone()), + referrer: ctx.referrer.clone(), + request_id: ctx.request_id.clone(), + // Batch writer will enrich these fields + ip_geolocation_id: None, + browser: None, + browser_version: None, + operating_system: None, + device_type: None, + is_bot: None, + bot_name: None, + request_size_bytes: request_size, + response_size_bytes: response_size, + cache_status, + request_headers: ctx + .request_headers + .as_ref() + .and_then(|h| serde_json::to_value(h).ok()), + response_headers: ctx + .response_headers + .as_ref() + .and_then(|h| serde_json::to_value(h).ok()), + }; - // Send to batch writer with backpressure (blocks briefly if buffer full) - let handle = self.proxy_log_handle.clone(); - tokio::spawn(async move { - if !handle.send(proxy_log_request).await { - warn!("Proxy log batch writer is closed, log entry dropped"); - } - }); - } + // Send to batch writer with backpressure (blocks briefly if buffer full) + let handle = self.proxy_log_handle.clone(); + tokio::spawn(async move { + if !handle.send(proxy_log_request).await { + warn!("Proxy log batch writer is closed, log entry dropped"); + } + }); } Ok(()) diff --git a/crates/temps-query-mongodb/src/lib.rs b/crates/temps-query-mongodb/src/lib.rs index 8848ff303..d6f4c6bf2 100644 --- a/crates/temps-query-mongodb/src/lib.rs +++ b/crates/temps-query-mongodb/src/lib.rs @@ -53,11 +53,20 @@ impl MongoDBSource { pub async fn new(url: &str) -> Result { debug!("Creating MongoDB source for URL: {}", url); - let client_options = ClientOptions::parse(url).await.map_err(|e| { + let mut client_options = ClientOptions::parse(url).await.map_err(|e| { error!("Failed to parse MongoDB URL: {}", e); DataError::ConnectionFailed(format!("Failed to parse MongoDB URL: {}", e)) })?; + // Force direct connection so the driver doesn't perform replica-set + // topology discovery. In RS mode, `rs.status()` advertises members at + // their internal addresses (e.g. `127.0.0.1:27017` from the + // container's POV) which are unreachable from the explorer process. + // Without this flag, `list_database_names()` hangs on `ReplicaSetNoPrimary` + // until server-selection timeout. The explorer is inherently per-node, + // not per-cluster, so direct connection is the correct semantic. + client_options.direct_connection = Some(true); + let client = Client::with_options(client_options).map_err(|e| { error!("Failed to create MongoDB client: {}", e); DataError::ConnectionFailed(format!("Failed to create MongoDB client: {}", e)) diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts index fbab3ec74..668a99e53 100644 --- a/web/rsbuild.config.ts +++ b/web/rsbuild.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:80', + target: 'http://localhost:8080', headers: {}, changeOrigin: true, ws: true, diff --git a/web/src/api/client/@tanstack/react-query.gen.ts b/web/src/api/client/@tanstack/react-query.gen.ts index 1418e0646..791e2db0b 100644 --- a/web/src/api/client/@tanstack/react-query.gen.ts +++ b/web/src/api/client/@tanstack/react-query.gen.ts @@ -1,8 +1,8 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type Options, getPlatformInfo, chunkUploadOptions, createRelease, createProjectRelease, finalizeProjectRelease, listReleaseFiles, uploadReleaseFile, recordEventMetrics, addSessionReplayEvents, initSessionReplay, recordSpeedMetrics, updateSpeedMetrics, webhookTrigger, getPricing, listProviderKeys, createProviderKey, testProviderKeyInline, deleteProviderKey, updateProviderKey, testProviderKeyById, getUsageByProvider, getConversations, getConversationDetail, getUsageRecent, getUsageSummary, getUsageTimeseries, getUsageTopModels, chatCompletions, embeddings, listModels, getAnalyticsActiveVisitors, getEventDetail, getEventVisitors, getAnalyticsEventsCount, getGeneralStats, getLiveVisitorsList, getPageFlow, getPageHourlySessions, getPagePathDetail, getPagePathVisitors, getPagePaths, getPagePathsSparklines, getRecentActivity, getSessionDetails, getAnalyticsSessionEvents, getSessionLogs, getVisitors, getVisitorByGuid, getVisitorById, getVisitorDetails, enrichVisitor, getVisitorInfo, getVisitorJourney, getAnalyticsVisitorSessions, getVisitorStats, listApiKeys, createApiKey, getApiKeyPermissions, deleteApiKey, getApiKey, updateApiKey, activateApiKey, deactivateApiKey, emailStatus, login, requestMagicLink, verifyMagicLink, requestPasswordReset, resetPassword, verifyEmail, verifyMfaChallenge, runExternalServiceBackup, listS3Sources, createS3Source, testS3ConnectionPreview, deleteS3Source, getS3Source, updateS3Source, listSourceBackups, runBackupForSource, setDefaultS3Source, testS3SourceConnection, listBackupSchedules, createBackupSchedule, deleteBackupSchedule, getBackupSchedule, listBackupsForSchedule, disableBackupSchedule, enableBackupSchedule, getBackup, blobDelete, blobList, blobPut, blobCopy, blobDisable, blobEnable, blobStatus, blobUpdate, blobDownload, getDashboardProjectsAnalytics, getActivityGraph, getScanByDeployment, listDnsProviders, createDnsProvider, deleteDnsProvider, getDnsProvider, updateProvider, listManagedDomains, addManagedDomain, testProviderConnection, listProviderZones, removeManagedDomain, verifyManagedDomain, lookupDnsARecords, listDomains, createDomain, getDomainByHost, cancelDomainOrder, getDomainOrder, createOrRecreateOrder, finalizeOrder, setupDnsChallenge, deleteDomain, getDomainById, getChallengeToken, getHttpChallengeDebug, provisionDomain, renewDomain, checkDomainStatus, listEmailDomains, createEmailDomain, getDomainByName, deleteEmailDomain, getDomain, getDomainDnsRecords, setupDns, verifyDomain, listEmailProviders, createEmailProvider, deleteEmailProvider, getEmailProvider, testProvider, listEmails, sendEmail, getGlobalEvents, getGlobalEventStats, getEmailStats, validateEmail, trackClick, trackOpen, getEmail, getEmailTracking, getEmailEvents, getEmailLinks, listServices, createService, listAvailableContainers, getServiceBySlug, listServiceHealthStatuses, importExternalService, listProjectServices, getProjectServiceEnvironmentVariables, getProvidersMetadata, getProviderMetadata, getServiceTypes, getServiceTypeParameters, deleteService, getService, updateService, getClusterHealth, triggerServiceHealthCheck, getServiceHealthStatus, addClusterMember, removeClusterMember, getClusterMember, promoteClusterMember, getServicePreviewEnvironmentVariablesMasked, getServicePreviewEnvironmentVariableNames, listServiceProjects, linkServiceToProject, unlinkServiceFromProject, getServiceEnvironmentVariables, getServiceEnvironmentVariable, startRestore, getRestoreCapabilities, planRestore, listRestoreRunsForService, retryCluster, startService, stopService, upgradeService, listRootContainers, listContainersAtPath, listEntities, getEntityInfo, queryData, downloadObject, getContainerInfo, checkExplorerSupport, listPgUpgrades, startPgUpgrade, getPgUpgrade, cancelPgUpgrade, getPgUpgradeLogs, retryPgUpgrade, rollbackPgUpgrade, getFile, getIpGeolocation, listConnections, deleteConnection, activateConnection, deactivateConnection, runConnectionHealthCheck, listRepositoriesByConnection, syncRepositories, updateConnectionToken, validateConnection, listGitProviders, createGitProvider, createGithubPatProvider, createGitlabOauthProvider, createGitlabPatProvider, deleteGitProvider, getGitProvider, activateProvider, handleGitProviderOauthCallback, getProviderConnections, updateGitProviderCredentials, deactivateProvider, checkProviderDeletionSafety, startGitProviderOauth, deleteProviderSafely, getPublicRepository, getPublicBranches, detectPublicPresets, discoverWorkloads, executeImport, createPlan, listSources, getImportStatus, getIncident, updateIncidentStatus, getIncidentUpdates, adminListNodes, registerNode, adminRemoveNode, adminGetNode, adminListNodeContainers, postDnsAck, getDnsChanges, adminUndrainNode, adminDrainStatus, adminDrainNode, nodeHeartbeat, listPeers, getS3Credentials, listIpAccessControl, createIpAccessControl, checkIpBlocked, deleteIpAccessControl, getIpAccessControl, updateIpAccessControl, kvDel, kvDisable, kvEnable, kvExpire, kvGet, kvIncr, kvKeys, kvSet, kvStatus, kvTtl, kvUpdate, listRoutes, createRoute, deleteRoute, getRoute, updateRoute, logout, getLogContext, searchLogs, tailLogs, getProjectsMonitorHealth, deleteMonitor, getMonitor, getBucketedStatus, getCurrentMonitorStatus, getUptimeHistory, deletePreferences, getPreferences, updatePreferences, listNotificationProviders, createNotificationProvider, createNotificationEmailProvider, updateEmailProvider, createSlackProvider, updateSlackProvider, createWebhookProvider, updateWebhookProvider, deleteNotificationProvider, getNotificationProvider, updateNotificationProvider, testNotificationProvider, listOrders, queryGenaiTraces, getGenaiTrace, getHealth, listInsights, queryLogs, listMetricNames, queryMetrics, getPipelineStats, getQuota, queryTraceSummaries, queryTraces, getTrace, ingestLogs, ingestMetrics, ingestTraces, ingestLogsByPath, ingestMetricsByPath, ingestTracesByPath, hasPerformanceMetrics, getPerformanceMetrics, getMetricsOverTime, getGroupedPageMetrics, getAccessInfo, getPrivateIp, getPublicIp, listPresets, generatePresetDockerfile, getPreviewGatewayLogs, restartPreviewGateway, getPreviewGatewaySettings, patchPreviewGatewaySettings, getPreviewGatewayStatus, upgradePreviewGateway, getProjects, createProject, getProjectBySlug, createProjectFromTemplate, getProjectStatistics, deleteProject, getProject, updateProject, getProjectDeployments, getLastDeployment, triggerProjectPipeline, getActiveVisitors, listAgents, createAgent, getCliStatus, listAllRuns, latestRunForSource, getRunWithLogs, cancelRun, retryRun, getSandboxStatus, smokeTestAgent, deleteAgent, getAgent, updateAgent, listAgentRuns, triggerAgent, getAggregatedBuckets, startAnalysis, getRun, addContext, cancel, createPr, startFix, reAnalyze, updateAutomaticDeploy, listCustomDomainsForProject, createCustomDomain, deleteCustomDomain, getCustomDomain, updateCustomDomain, linkCustomDomainToCertificate, updateProjectDeploymentConfig, getDeployment, cancelDeployment, getDeploymentJobs, getDeploymentJobLogs, tailDeploymentJobLogs, getDeploymentOperations, executeDeploymentOperation, getDeploymentOperationStatus, pauseDeployment, promoteDeployment, resumeDeployment, rollbackToDeployment, teardownDeployment, listDsns, createDsn, getOrCreateDsn, regenerateDsn, revokeDsn, getEnvironmentVariables, createEnvironmentVariable, getResolvedEnvironmentVariables, getResolvedEnvironmentVariableValue, getEnvironmentVariableValue, deleteEnvironmentVariable, updateEnvironmentVariable, getEnvironments, createEnvironment, deleteEnvironment, getEnvironment, getEnvironmentCrons, getCronById, getCronExecutions, getEnvironmentDomains, addEnvironmentDomain, deleteEnvironmentDomain, updateEnvironmentSettings, sleepEnvironment, teardownEnvironment, wakeEnvironment, getContainerLogs, listContainers, getContainerDetail, getContainerLogsById, getContainerMetrics, streamContainerMetrics, restartContainer, startContainer, stopContainer, deployFromImage, deployFromImageUpload, deployFromStatic, listAlertRules, createAlertRule, deleteAlertRule, getAlertRule, updateAlertRule, getErrorDashboardStats, listErrorGroups, getErrorGroup, updateErrorGroup, listErrorEvents, getErrorEvent, getErrorStats, getErrorTimeSeries, getEventsCount, getEventTypeBreakdown, recordConsoleEvent, getPropertyBreakdown, getPropertyTimeline, getEventsTimeline, getUniqueEvents, listRemoteExternalImages, registerExternalImage, deleteExternalImage, getRemoteExternalImage, listFunnels, createFunnel, previewFunnelMetrics, deleteFunnel, updateFunnel, getFunnelMetrics, updateGitSettings, hasErrorGroups, hasAnalyticsEvents, getHourlyVisits, listExternalImages, pushExternalImage, getExternalImage, listIncidents, createIncident, getBucketedIncidents, purgeProjectLogs, listMcps, createMcp, deleteMcp, getMcp, updateMcp, listMonitors, createMonitor, deleteReleaseSourceMaps, listSourceMaps, uploadSourceMap, revenueRecentEvents, revenueListIntegrations, revenueCreateIntegration, revenueDeleteIntegration, revenueUpdateConfig, revenueImportInvoicesCsv, revenueImportSubscriptionsCsv, revenueRotateToken, revenueUpdateSecret, revenueMetricsCustomers, revenueMetricsMrr, revenueMetricsSummary, listProjectSecrets, createProjectSecret, deleteProjectSecret, updateProjectSecret, updateProjectSettings, listSkills, createSkill, uploadSkill, deleteSkill, getSkill, updateSkill, downloadSkillArchive, listReleases, deleteSourceMap, listStaticBundles, deleteStaticBundle, getStaticBundle, getStatusOverview, getUniqueCounts, uploadStaticBundle, listProjectScans, triggerScan, getLatestScansPerEnvironment, getLatestScan, listWebhooks, createWebhook, deleteWebhook, getWebhook, updateWebhook, listDeliveries, getDelivery, retryDelivery, workflowDryRun, listMemory, writeMemory, searchMemory, dropMemory, supersedeMemory, workspaceListSessions, workspaceStartSession, workspaceDeleteSession, workspaceGetSession, workspaceUpdateSession, workspaceCancelRun, workspaceCloseSession, workspaceSendMessage, workspaceRegeneratePreviewPassword, workspaceReopenSession, workspaceRefreshSandbox, workspaceRestartSandbox, workspaceStartSandbox, workspaceSandboxStats, workspaceStopSandbox, workspaceTerminalPasteImage, workspaceListTerminalTabs, workspaceDeleteTerminalTab, getProxyLogs, getProxyLogByRequestId, getProjectsHealth, getTimeBucketStats, getTodayStats, getProxyLogById, listSyncedRepositories, getRepositoryByName, getAllRepositoriesByName, getRepositoryPresetByName, getRepositoryBranches, getRepositoryTags, getRepositoryPresetLive, getRepositoryById, getBranchesByRepositoryId, listCommitsByRepositoryId, checkCommitExists, getTagsByRepositoryId, getRestoreRun, revenueGlobalEvents, revenueMetricsGlobalMrr, revenueMetricsGlobalSummary, revenueListProviders, getProjectSessionReplays, getSessionEvents, getSettings, updateSettings, saveAgentToken, listAiProviders, updateAiProvider, activateAiProvider, saveAiProviderCredential, revokeJoinToken, generateJoinToken, getJoinTokenStatus, listGlobalMcps, createGlobalMcp, deleteGlobalMcp, getGlobalMcp, updateGlobalMcp, refreshRouteTable, rebuildSandboxImage, getGlobalSandboxStatus, listSecrets, upsertSecret, deleteSecret, listGlobalSkills, createGlobalSkill, uploadGlobalSkill, deleteGlobalSkill, getGlobalSkill, updateGlobalSkill, downloadGlobalSkillArchive, listProjectTemplates, listProjectTemplateTags, getProjectTemplate, getCurrentUser, listUsers, createUser, updateSelf, disableMfa, setupMfa, verifyAndEnableMfa, deleteUser, updateUser, restoreUser, assignRole, removeRole, listSandboxes, createSandbox, getSandbox, cmd, getCmd, cmdLogs, destroySandbox, domain, exec, execDetached, extendTimeout, mkdir, readFile, statPath, writeFile, writeFiles, listJobs, jobStatus, killJob, jobLogs, pauseSandbox, clearPreviewPassword, setPreviewPassword, restartSandbox, resumeSandbox, sourceSandbox, stopSandbox, cmdKill, getVisitorSessions, deleteSessionReplay, getSessionReplay, updateSessionDuration, getSessionReplayEvents, addEvents, deleteScan, getScan, getScanVulnerabilities, listEventTypes, triggerWeeklyDigest, listExternalPlugins, reloadPlugins, ingestSentryEnvelope, ingestSentryEvent, listAuditLogs, getAuditLog } from '../sdk.gen'; +import { type Options, getPlatformInfo, chunkUploadOptions, createRelease, createProjectRelease, finalizeProjectRelease, listReleaseFiles, uploadReleaseFile, recordEventMetrics, addSessionReplayEvents, initSessionReplay, recordSpeedMetrics, updateSpeedMetrics, webhookTrigger, getPricing, listProviderKeys, createProviderKey, testProviderKeyInline, deleteProviderKey, updateProviderKey, testProviderKeyById, getUsageByProvider, getConversations, getConversationDetail, getUsageRecent, getUsageSummary, getUsageTimeseries, getUsageTopModels, chatCompletions, embeddings, listModels, getAnalyticsActiveVisitors, getEventDetail, getEventVisitors, getAnalyticsEventsCount, getGeneralStats, getLiveVisitorsList, getPageFlow, getPageHourlySessions, getPagePathDetail, getPagePathVisitors, getPagePaths, getPagePathsSparklines, getRecentActivity, getSessionDetails, getAnalyticsSessionEvents, getSessionLogs, getVisitors, getVisitorByGuid, getVisitorById, getVisitorDetails, enrichVisitor, getVisitorInfo, getVisitorJourney, getAnalyticsVisitorSessions, getVisitorStats, listApiKeys, createApiKey, getApiKeyPermissions, deleteApiKey, getApiKey, updateApiKey, activateApiKey, deactivateApiKey, cliLogin, cliLogout, emailStatus, login, requestMagicLink, verifyMagicLink, requestPasswordReset, resetPassword, verifyEmail, verifyMfaChallenge, runExternalServiceBackup, listS3Sources, createS3Source, testS3ConnectionPreview, deleteS3Source, getS3Source, updateS3Source, listSourceBackups, runBackupForSource, setDefaultS3Source, testS3SourceConnection, listBackupSchedules, createBackupSchedule, deleteBackupSchedule, getBackupSchedule, listBackupsForSchedule, disableBackupSchedule, enableBackupSchedule, getBackup, blobDelete, blobList, blobPut, blobCopy, blobDisable, blobEnable, blobStatus, blobUpdate, blobDownload, getDashboardProjectsAnalytics, getActivityGraph, getScanByDeployment, listDnsProviders, createDnsProvider, deleteDnsProvider, getDnsProvider, updateProvider, listManagedDomains, addManagedDomain, testProviderConnection, listProviderZones, removeManagedDomain, verifyManagedDomain, lookupDnsARecords, listDomains, createDomain, getDomainByHost, cancelDomainOrder, getDomainOrder, createOrRecreateOrder, finalizeOrder, setupDnsChallenge, deleteDomain, getDomainById, getChallengeToken, getHttpChallengeDebug, provisionDomain, renewDomain, checkDomainStatus, listEmailDomains, createEmailDomain, getDomainByName, deleteEmailDomain, getDomain, getDomainDnsRecords, setupDns, verifyDomain, listEmailProviders, createEmailProvider, deleteEmailProvider, getEmailProvider, testProvider, listEmails, sendEmail, getGlobalEvents, getGlobalEventStats, getEmailStats, validateEmail, trackClick, trackOpen, getEmail, getEmailTracking, getEmailEvents, getEmailLinks, listServices, createService, listAvailableContainers, getServiceBySlug, listServiceHealthStatuses, importExternalService, listProjectServices, getProjectServiceEnvironmentVariables, getProvidersMetadata, getProviderMetadata, getServiceTypes, getServiceTypeParameters, deleteService, getService, updateService, getClusterHealth, triggerServiceHealthCheck, getServiceHealthStatus, addClusterMember, removeClusterMember, getClusterMember, promoteClusterMember, getServicePreviewEnvironmentVariablesMasked, getServicePreviewEnvironmentVariableNames, listServiceProjects, linkServiceToProject, unlinkServiceFromProject, getServiceEnvironmentVariables, getServiceEnvironmentVariable, startRestore, getRestoreCapabilities, planRestore, listRestoreRunsForService, retryCluster, startService, stopService, upgradeService, listRootContainers, listContainersAtPath, listEntities, getEntityInfo, queryData, downloadObject, getContainerInfo, checkExplorerSupport, listPgUpgrades, startPgUpgrade, getPgUpgrade, cancelPgUpgrade, getPgUpgradeLogs, retryPgUpgrade, rollbackPgUpgrade, getFile, getIpGeolocation, listConnections, deleteConnection, activateConnection, deactivateConnection, runConnectionHealthCheck, listRepositoriesByConnection, syncRepositories, updateConnectionToken, validateConnection, listGitProviders, createGitProvider, createGithubPatProvider, createGitlabOauthProvider, createGitlabPatProvider, deleteGitProvider, getGitProvider, activateProvider, handleGitProviderOauthCallback, getProviderConnections, updateGitProviderCredentials, deactivateProvider, checkProviderDeletionSafety, startGitProviderOauth, deleteProviderSafely, getPublicRepository, getPublicBranches, detectPublicPresets, discoverWorkloads, executeImport, createPlan, listSources, getImportStatus, getIncident, updateIncidentStatus, getIncidentUpdates, adminListNodes, registerNode, adminRemoveNode, adminGetNode, adminListNodeContainers, postDnsAck, getDnsChanges, adminUndrainNode, adminDrainStatus, adminDrainNode, nodeHeartbeat, listPeers, getS3Credentials, listIpAccessControl, createIpAccessControl, checkIpBlocked, deleteIpAccessControl, getIpAccessControl, updateIpAccessControl, kvDel, kvDisable, kvEnable, kvExpire, kvGet, kvIncr, kvKeys, kvSet, kvStatus, kvTtl, kvUpdate, listRoutes, createRoute, deleteRoute, getRoute, updateRoute, logout, getLogContext, searchLogs, tailLogs, getProjectsMonitorHealth, deleteMonitor, getMonitor, getBucketedStatus, getCurrentMonitorStatus, getUptimeHistory, deletePreferences, getPreferences, updatePreferences, listNotificationProviders, createNotificationProvider, createNotificationEmailProvider, updateEmailProvider, createSlackProvider, updateSlackProvider, createWebhookProvider, updateWebhookProvider, deleteNotificationProvider, getNotificationProvider, updateNotificationProvider, testNotificationProvider, listOrders, queryGenaiTraces, getGenaiTrace, getHealth, listInsights, queryLogs, listMetricNames, queryMetrics, getPipelineStats, getQuota, queryTraceSummaries, queryTraces, getTrace, ingestLogs, ingestMetrics, ingestTraces, ingestLogsByPath, ingestMetricsByPath, ingestTracesByPath, hasPerformanceMetrics, getPerformanceMetrics, getMetricsOverTime, getGroupedPageMetrics, getAccessInfo, getPrivateIp, getPublicIp, listPresets, generatePresetDockerfile, getPreviewGatewayLogs, restartPreviewGateway, getPreviewGatewaySettings, patchPreviewGatewaySettings, getPreviewGatewayStatus, upgradePreviewGateway, getProjects, createProject, getProjectBySlug, createProjectFromTemplate, getProjectStatistics, deleteProject, getProject, updateProject, getProjectDeployments, getLastDeployment, triggerProjectPipeline, getActiveVisitors, listAgents, createAgent, getCliStatus, listAllRuns, latestRunForSource, getRunWithLogs, cancelRun, retryRun, getSandboxStatus, smokeTestAgent, deleteAgent, getAgent, updateAgent, listAgentRuns, triggerAgent, getAggregatedBuckets, startAnalysis, getRun, addContext, cancel, createPr, startFix, reAnalyze, updateAutomaticDeploy, listCustomDomainsForProject, createCustomDomain, deleteCustomDomain, getCustomDomain, updateCustomDomain, linkCustomDomainToCertificate, updateProjectDeploymentConfig, getDeployment, cancelDeployment, getDeploymentJobs, getDeploymentJobLogs, tailDeploymentJobLogs, getDeploymentOperations, executeDeploymentOperation, getDeploymentOperationStatus, pauseDeployment, promoteDeployment, resumeDeployment, rollbackToDeployment, teardownDeployment, listDsns, createDsn, getOrCreateDsn, regenerateDsn, revokeDsn, getEnvironmentVariables, createEnvironmentVariable, getResolvedEnvironmentVariables, getResolvedEnvironmentVariableValue, getEnvironmentVariableValue, deleteEnvironmentVariable, updateEnvironmentVariable, getEnvironments, createEnvironment, deleteEnvironment, getEnvironment, getEnvironmentCrons, getCronById, getCronExecutions, getEnvironmentDomains, addEnvironmentDomain, deleteEnvironmentDomain, updateEnvironmentSettings, sleepEnvironment, teardownEnvironment, wakeEnvironment, getContainerLogs, listContainers, getContainerDetail, getContainerLogsById, getContainerMetrics, streamContainerMetrics, restartContainer, startContainer, stopContainer, deployFromImage, deployFromImageUpload, deployFromStatic, listAlertRules, createAlertRule, deleteAlertRule, getAlertRule, updateAlertRule, getErrorDashboardStats, listErrorGroups, getErrorGroup, updateErrorGroup, listErrorEvents, getErrorEvent, getErrorStats, getErrorTimeSeries, getEventsCount, getEventTypeBreakdown, recordConsoleEvent, getPropertyBreakdown, getPropertyTimeline, getEventsTimeline, getUniqueEvents, listRemoteExternalImages, registerExternalImage, deleteExternalImage, getRemoteExternalImage, listFunnels, createFunnel, previewFunnelMetrics, deleteFunnel, updateFunnel, getFunnelMetrics, updateGitSettings, reinstallGitlabWebhook, hasErrorGroups, hasAnalyticsEvents, getHourlyVisits, listExternalImages, pushExternalImage, getExternalImage, listIncidents, createIncident, getBucketedIncidents, purgeProjectLogs, listMcps, createMcp, deleteMcp, getMcp, updateMcp, listMonitors, createMonitor, deleteReleaseSourceMaps, listSourceMaps, uploadSourceMap, revenueRecentEvents, revenueListIntegrations, revenueCreateIntegration, revenueDeleteIntegration, revenueUpdateConfig, revenueImportInvoicesCsv, revenueImportSubscriptionsCsv, revenueRotateToken, revenueUpdateSecret, revenueMetricsCustomers, revenueMetricsMrr, revenueMetricsSummary, listProjectSecrets, createProjectSecret, deleteProjectSecret, updateProjectSecret, updateProjectSettings, listSkills, createSkill, uploadSkill, deleteSkill, getSkill, updateSkill, downloadSkillArchive, listReleases, deleteSourceMap, listStaticBundles, deleteStaticBundle, getStaticBundle, getStatusOverview, getUniqueCounts, uploadStaticBundle, listProjectScans, triggerScan, getLatestScansPerEnvironment, getLatestScan, listWebhooks, createWebhook, deleteWebhook, getWebhook, updateWebhook, listDeliveries, getDelivery, retryDelivery, workflowDryRun, listMemory, writeMemory, searchMemory, dropMemory, supersedeMemory, workspaceListSessions, workspaceStartSession, workspaceDeleteSession, workspaceGetSession, workspaceUpdateSession, workspaceCancelRun, workspaceCloseSession, workspaceSendMessage, workspaceRegeneratePreviewPassword, workspaceReopenSession, workspaceRefreshSandbox, workspaceRestartSandbox, workspaceStartSandbox, workspaceSandboxStats, workspaceStopSandbox, workspaceTerminalPasteImage, workspaceListTerminalTabs, workspaceDeleteTerminalTab, getProxyLogs, getProxyLogByRequestId, getProjectsHealth, getTimeBucketStats, getTodayStats, getProxyLogById, listSyncedRepositories, getRepositoryByName, getAllRepositoriesByName, getRepositoryPresetByName, getRepositoryBranches, getRepositoryTags, getRepositoryPresetLive, getRepositoryById, getBranchesByRepositoryId, listCommitsByRepositoryId, checkCommitExists, getTagsByRepositoryId, getRestoreRun, revenueGlobalEvents, revenueMetricsGlobalMrr, revenueMetricsGlobalSummary, revenueListProviders, getProjectSessionReplays, getSessionEvents, getSettings, updateSettings, saveAgentToken, listAiProviders, updateAiProvider, activateAiProvider, saveAiProviderCredential, revokeJoinToken, generateJoinToken, getJoinTokenStatus, listGlobalMcps, createGlobalMcp, deleteGlobalMcp, getGlobalMcp, updateGlobalMcp, refreshRouteTable, rebuildSandboxImage, getGlobalSandboxStatus, listSecrets, upsertSecret, deleteSecret, listGlobalSkills, createGlobalSkill, uploadGlobalSkill, deleteGlobalSkill, getGlobalSkill, updateGlobalSkill, downloadGlobalSkillArchive, listProjectTemplates, listProjectTemplateTags, getProjectTemplate, getCurrentUser, listUsers, createUser, updateSelf, disableMfa, setupMfa, verifyAndEnableMfa, deleteUser, updateUser, restoreUser, assignRole, removeRole, listSandboxes, createSandbox, getSandbox, cmd, getCmd, cmdLogs, destroySandbox, domain, exec, execDetached, extendTimeout, mkdir, readFile, statPath, writeFile, writeFiles, listJobs, jobStatus, killJob, jobLogs, pauseSandbox, clearPreviewPassword, setPreviewPassword, restartSandbox, resumeSandbox, sourceSandbox, stopSandbox, cmdKill, getVisitorSessions, deleteSessionReplay, getSessionReplay, updateSessionDuration, getSessionReplayEvents, addEvents, deleteScan, getScan, getScanVulnerabilities, listEventTypes, triggerWeeklyDigest, listExternalPlugins, reloadPlugins, ingestSentryEnvelope, ingestSentryEvent, listAuditLogs, getAuditLog } from '../sdk.gen'; import { queryOptions, type UseMutationOptions, type DefaultError, infiniteQueryOptions, type InfiniteData } from '@tanstack/react-query'; -import type { GetPlatformInfoData, ChunkUploadOptionsData, CreateReleaseData, CreateReleaseResponse, CreateProjectReleaseData, CreateProjectReleaseResponse, FinalizeProjectReleaseData, FinalizeProjectReleaseResponse, ListReleaseFilesData, UploadReleaseFileData, UploadReleaseFileResponse, RecordEventMetricsData, RecordEventMetricsResponse, AddSessionReplayEventsData, AddSessionReplayEventsError, AddSessionReplayEventsResponse, InitSessionReplayData, InitSessionReplayError, InitSessionReplayResponse, RecordSpeedMetricsData, RecordSpeedMetricsError, RecordSpeedMetricsResponse, UpdateSpeedMetricsData, UpdateSpeedMetricsError, UpdateSpeedMetricsResponse, WebhookTriggerData, WebhookTriggerResponse2 as WebhookTriggerResponse, GetPricingData, ListProviderKeysData, CreateProviderKeyData, CreateProviderKeyError, CreateProviderKeyResponse, TestProviderKeyInlineData, TestProviderKeyInlineError, TestProviderKeyInlineResponse, DeleteProviderKeyData, DeleteProviderKeyError, DeleteProviderKeyResponse, UpdateProviderKeyData, UpdateProviderKeyError, UpdateProviderKeyResponse, TestProviderKeyByIdData, TestProviderKeyByIdError, TestProviderKeyByIdResponse, GetUsageByProviderData, GetConversationsData, GetConversationDetailData, GetUsageRecentData, GetUsageSummaryData, GetUsageTimeseriesData, GetUsageTopModelsData, ChatCompletionsData, ChatCompletionsError, ChatCompletionsResponse, EmbeddingsData, EmbeddingsError, EmbeddingsResponse, ListModelsData, GetAnalyticsActiveVisitorsData, GetEventDetailData, GetEventVisitorsData, GetEventVisitorsResponse, GetAnalyticsEventsCountData, GetGeneralStatsData, GetLiveVisitorsListData, GetPageFlowData, GetPageHourlySessionsData, GetPagePathDetailData, GetPagePathVisitorsData, GetPagePathVisitorsResponse, GetPagePathsData, GetPagePathsSparklinesData, GetRecentActivityData, GetSessionDetailsData, GetAnalyticsSessionEventsData, GetAnalyticsSessionEventsResponse, GetSessionLogsData, GetSessionLogsResponse, GetVisitorsData, GetVisitorsResponse, GetVisitorByGuidData, GetVisitorByIdData, GetVisitorDetailsData, EnrichVisitorData, EnrichVisitorResponse2 as EnrichVisitorResponse, GetVisitorInfoData, GetVisitorJourneyData, GetAnalyticsVisitorSessionsData, GetVisitorStatsData, ListApiKeysData, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyResponse2 as CreateApiKeyResponse, GetApiKeyPermissionsData, DeleteApiKeyData, DeleteApiKeyResponse, GetApiKeyData, UpdateApiKeyData, UpdateApiKeyResponse, ActivateApiKeyData, ActivateApiKeyResponse, DeactivateApiKeyData, DeactivateApiKeyResponse, EmailStatusData, LoginData, LoginResponse, RequestMagicLinkData, RequestMagicLinkResponse, VerifyMagicLinkData, RequestPasswordResetData, RequestPasswordResetResponse, ResetPasswordData, ResetPasswordResponse, VerifyEmailData, VerifyMfaChallengeData, VerifyMfaChallengeResponse, RunExternalServiceBackupData, RunExternalServiceBackupError, RunExternalServiceBackupResponse, ListS3SourcesData, CreateS3SourceData, CreateS3SourceError, CreateS3SourceResponse, TestS3ConnectionPreviewData, TestS3ConnectionPreviewError, TestS3ConnectionPreviewResponse, DeleteS3SourceData, DeleteS3SourceError, DeleteS3SourceResponse, GetS3SourceData, UpdateS3SourceData, UpdateS3SourceError, UpdateS3SourceResponse, ListSourceBackupsData, RunBackupForSourceData, RunBackupForSourceError, RunBackupForSourceResponse, SetDefaultS3SourceData, SetDefaultS3SourceError, SetDefaultS3SourceResponse, TestS3SourceConnectionData, TestS3SourceConnectionError, TestS3SourceConnectionResponse, ListBackupSchedulesData, CreateBackupScheduleData, CreateBackupScheduleError, CreateBackupScheduleResponse, DeleteBackupScheduleData, DeleteBackupScheduleError, DeleteBackupScheduleResponse, GetBackupScheduleData, ListBackupsForScheduleData, DisableBackupScheduleData, DisableBackupScheduleResponse, EnableBackupScheduleData, EnableBackupScheduleResponse, GetBackupData, BlobDeleteData, BlobDeleteError, BlobDeleteResponse, BlobListData, BlobListError, BlobListResponse, BlobPutData, BlobPutError, BlobPutResponse, BlobCopyData, BlobCopyError, BlobCopyResponse, BlobDisableData, BlobDisableResponse, BlobEnableData, BlobEnableResponse, BlobStatusData, BlobUpdateData, BlobUpdateResponse, BlobDownloadData, GetDashboardProjectsAnalyticsData, GetActivityGraphData, GetScanByDeploymentData, ListDnsProvidersData, CreateDnsProviderData, CreateDnsProviderResponse, DeleteDnsProviderData, DeleteDnsProviderResponse, GetDnsProviderData, UpdateProviderData, UpdateProviderResponse, ListManagedDomainsData, AddManagedDomainData, AddManagedDomainResponse, TestProviderConnectionData, TestProviderConnectionResponse, ListProviderZonesData, RemoveManagedDomainData, RemoveManagedDomainResponse, VerifyManagedDomainData, VerifyManagedDomainResponse, LookupDnsARecordsData, ListDomainsData, ListDomainsResponse2 as ListDomainsResponse, CreateDomainData, CreateDomainResponse, GetDomainByHostData, CancelDomainOrderData, CancelDomainOrderResponse, GetDomainOrderData, CreateOrRecreateOrderData, CreateOrRecreateOrderResponse, FinalizeOrderData, FinalizeOrderResponse, SetupDnsChallengeData, SetupDnsChallengeResponse2 as SetupDnsChallengeResponse, DeleteDomainData, DeleteDomainResponse, GetDomainByIdData, GetChallengeTokenData, GetHttpChallengeDebugData, ProvisionDomainData, ProvisionDomainResponse, RenewDomainData, RenewDomainResponse, CheckDomainStatusData, ListEmailDomainsData, CreateEmailDomainData, CreateEmailDomainResponse, GetDomainByNameData, DeleteEmailDomainData, DeleteEmailDomainResponse, GetDomainData, GetDomainDnsRecordsData, SetupDnsData, SetupDnsResponse2 as SetupDnsResponse, VerifyDomainData, VerifyDomainResponse, ListEmailProvidersData, CreateEmailProviderData, CreateEmailProviderResponse, DeleteEmailProviderData, DeleteEmailProviderResponse, GetEmailProviderData, TestProviderData, TestProviderResponse2 as TestProviderResponse, ListEmailsData, ListEmailsResponse, SendEmailData, SendEmailResponse, GetGlobalEventsData, GetGlobalEventsResponse, GetGlobalEventStatsData, GetEmailStatsData, ValidateEmailData, ValidateEmailResponse2 as ValidateEmailResponse, TrackClickData, TrackOpenData, GetEmailData, GetEmailTrackingData, GetEmailEventsData, GetEmailLinksData, ListServicesData, ListServicesResponse, CreateServiceData, CreateServiceResponse, ListAvailableContainersData, GetServiceBySlugData, ListServiceHealthStatusesData, ImportExternalServiceData, ImportExternalServiceResponse, ListProjectServicesData, ListProjectServicesResponse, GetProjectServiceEnvironmentVariablesData, GetProvidersMetadataData, GetProviderMetadataData, GetServiceTypesData, GetServiceTypeParametersData, DeleteServiceData, DeleteServiceResponse, GetServiceData, UpdateServiceData, UpdateServiceResponse, GetClusterHealthData, TriggerServiceHealthCheckData, TriggerServiceHealthCheckResponse, GetServiceHealthStatusData, AddClusterMemberData, AddClusterMemberResponse, RemoveClusterMemberData, RemoveClusterMemberResponse, GetClusterMemberData, PromoteClusterMemberData, GetServicePreviewEnvironmentVariablesMaskedData, GetServicePreviewEnvironmentVariableNamesData, ListServiceProjectsData, ListServiceProjectsResponse, LinkServiceToProjectData, LinkServiceToProjectResponse, UnlinkServiceFromProjectData, UnlinkServiceFromProjectResponse, GetServiceEnvironmentVariablesData, GetServiceEnvironmentVariableData, StartRestoreData, StartRestoreError, StartRestoreResponse, GetRestoreCapabilitiesData, PlanRestoreData, PlanRestoreError, PlanRestoreResponse, ListRestoreRunsForServiceData, RetryClusterData, RetryClusterResponse, StartServiceData, StartServiceResponse, StopServiceData, StopServiceResponse, UpgradeServiceData, UpgradeServiceResponse, ListRootContainersData, ListContainersAtPathData, ListEntitiesData, GetEntityInfoData, QueryDataData, QueryDataResponse2 as QueryDataResponse, DownloadObjectData, GetContainerInfoData, CheckExplorerSupportData, ListPgUpgradesData, StartPgUpgradeData, StartPgUpgradeResponse, GetPgUpgradeData, CancelPgUpgradeData, CancelPgUpgradeResponse, GetPgUpgradeLogsData, RetryPgUpgradeData, RetryPgUpgradeResponse, RollbackPgUpgradeData, RollbackPgUpgradeResponse, GetFileData, GetIpGeolocationData, ListConnectionsData, ListConnectionsResponse, DeleteConnectionData, DeleteConnectionResponse, ActivateConnectionData, DeactivateConnectionData, RunConnectionHealthCheckData, RunConnectionHealthCheckResponse, ListRepositoriesByConnectionData, ListRepositoriesByConnectionResponse, SyncRepositoriesData, SyncRepositoriesResponse, UpdateConnectionTokenData, UpdateConnectionTokenResponse, ValidateConnectionData, ListGitProvidersData, CreateGitProviderData, CreateGitProviderResponse, CreateGithubPatProviderData, CreateGithubPatProviderResponse, CreateGitlabOauthProviderData, CreateGitlabOauthProviderResponse, CreateGitlabPatProviderData, CreateGitlabPatProviderResponse, DeleteGitProviderData, DeleteGitProviderResponse, GetGitProviderData, ActivateProviderData, HandleGitProviderOauthCallbackData, GetProviderConnectionsData, UpdateGitProviderCredentialsData, UpdateGitProviderCredentialsResponse, DeactivateProviderData, CheckProviderDeletionSafetyData, StartGitProviderOauthData, DeleteProviderSafelyData, DeleteProviderSafelyResponse, GetPublicRepositoryData, GetPublicBranchesData, DetectPublicPresetsData, DiscoverWorkloadsData, DiscoverWorkloadsResponse, ExecuteImportData, ExecuteImportResponse2 as ExecuteImportResponse, CreatePlanData, CreatePlanResponse2 as CreatePlanResponse, ListSourcesData, GetImportStatusData, GetIncidentData, UpdateIncidentStatusData, UpdateIncidentStatusResponse, GetIncidentUpdatesData, AdminListNodesData, RegisterNodeData, RegisterNodeResponse2 as RegisterNodeResponse, AdminRemoveNodeData, AdminRemoveNodeResponse, AdminGetNodeData, AdminListNodeContainersData, PostDnsAckData, PostDnsAckResponse, GetDnsChangesData, AdminUndrainNodeData, AdminUndrainNodeResponse, AdminDrainStatusData, AdminDrainNodeData, AdminDrainNodeResponse, NodeHeartbeatData, NodeHeartbeatResponse, ListPeersData, GetS3CredentialsData, ListIpAccessControlData, CreateIpAccessControlData, CreateIpAccessControlError, CreateIpAccessControlResponse, CheckIpBlockedData, DeleteIpAccessControlData, DeleteIpAccessControlError, DeleteIpAccessControlResponse, GetIpAccessControlData, UpdateIpAccessControlData, UpdateIpAccessControlError, UpdateIpAccessControlResponse, KvDelData, KvDelResponse, KvDisableData, KvDisableResponse, KvEnableData, KvEnableResponse, KvExpireData, KvExpireResponse, KvGetData, KvGetResponse, KvIncrData, KvIncrResponse, KvKeysData, KvKeysResponse, KvSetData, KvSetResponse, KvStatusData, KvTtlData, KvTtlResponse, KvUpdateData, KvUpdateResponse, ListRoutesData, CreateRouteData, CreateRouteResponse, DeleteRouteData, DeleteRouteResponse, GetRouteData, UpdateRouteData, UpdateRouteResponse, LogoutData, GetLogContextData, SearchLogsData, SearchLogsError, SearchLogsResponse2 as SearchLogsResponse, TailLogsData, GetProjectsMonitorHealthData, DeleteMonitorData, DeleteMonitorResponse, GetMonitorData, GetBucketedStatusData, GetCurrentMonitorStatusData, GetUptimeHistoryData, DeletePreferencesData, DeletePreferencesResponse, GetPreferencesData, UpdatePreferencesData, UpdatePreferencesResponse, ListNotificationProvidersData, ListNotificationProvidersResponse, CreateNotificationProviderData, CreateNotificationProviderResponse, CreateNotificationEmailProviderData, CreateNotificationEmailProviderResponse, UpdateEmailProviderData, UpdateEmailProviderResponse, CreateSlackProviderData, CreateSlackProviderResponse, UpdateSlackProviderData, UpdateSlackProviderResponse, CreateWebhookProviderData, CreateWebhookProviderResponse, UpdateWebhookProviderData, UpdateWebhookProviderResponse, DeleteNotificationProviderData, DeleteNotificationProviderResponse, GetNotificationProviderData, UpdateNotificationProviderData, UpdateNotificationProviderResponse, TestNotificationProviderData, TestNotificationProviderResponse, ListOrdersData, ListOrdersResponse2 as ListOrdersResponse, QueryGenaiTracesData, QueryGenaiTracesError, QueryGenaiTracesResponse, GetGenaiTraceData, GetHealthData, ListInsightsData, ListInsightsError, ListInsightsResponse, QueryLogsData, QueryLogsError, QueryLogsResponse, ListMetricNamesData, QueryMetricsData, GetPipelineStatsData, GetQuotaData, QueryTraceSummariesData, QueryTraceSummariesError, QueryTraceSummariesResponse, QueryTracesData, QueryTracesError, QueryTracesResponse, GetTraceData, IngestLogsData, IngestLogsError, IngestMetricsData, IngestMetricsError, IngestTracesData, IngestTracesError, IngestLogsByPathData, IngestLogsByPathError, IngestMetricsByPathData, IngestMetricsByPathError, IngestTracesByPathData, IngestTracesByPathError, HasPerformanceMetricsData, GetPerformanceMetricsData, GetMetricsOverTimeData, GetGroupedPageMetricsData, GetAccessInfoData, GetPrivateIpData, GetPublicIpData, ListPresetsData, GeneratePresetDockerfileData, GeneratePresetDockerfileResponse, GetPreviewGatewayLogsData, RestartPreviewGatewayData, RestartPreviewGatewayResponse, GetPreviewGatewaySettingsData, PatchPreviewGatewaySettingsData, PatchPreviewGatewaySettingsResponse, GetPreviewGatewayStatusData, UpgradePreviewGatewayData, UpgradePreviewGatewayResponse, GetProjectsData, GetProjectsResponse, CreateProjectData, CreateProjectResponse, GetProjectBySlugData, CreateProjectFromTemplateData, CreateProjectFromTemplateResponse2 as CreateProjectFromTemplateResponse, GetProjectStatisticsData, DeleteProjectData, DeleteProjectResponse, GetProjectData, UpdateProjectData, UpdateProjectResponse, GetProjectDeploymentsData, GetProjectDeploymentsResponse, GetLastDeploymentData, TriggerProjectPipelineData, TriggerProjectPipelineResponse, GetActiveVisitorsData, ListAgentsData, CreateAgentData, CreateAgentResponse, GetCliStatusData, ListAllRunsData, ListAllRunsResponse, LatestRunForSourceData, GetRunWithLogsData, CancelRunData, CancelRunResponse, RetryRunData, RetryRunResponse, GetSandboxStatusData, SmokeTestAgentData, SmokeTestAgentResponse, DeleteAgentData, DeleteAgentResponse, GetAgentData, UpdateAgentData, UpdateAgentResponse, ListAgentRunsData, ListAgentRunsResponse, TriggerAgentData, TriggerAgentResponse, GetAggregatedBucketsData, StartAnalysisData, StartAnalysisResponse, GetRunData, AddContextData, CancelData, CreatePrData, CreatePrResponse2 as CreatePrResponse, StartFixData, ReAnalyzeData, UpdateAutomaticDeployData, UpdateAutomaticDeployResponse, ListCustomDomainsForProjectData, CreateCustomDomainData, CreateCustomDomainResponse, DeleteCustomDomainData, DeleteCustomDomainResponse, GetCustomDomainData, UpdateCustomDomainData, UpdateCustomDomainResponse, LinkCustomDomainToCertificateData, LinkCustomDomainToCertificateResponse, UpdateProjectDeploymentConfigData, UpdateProjectDeploymentConfigResponse, GetDeploymentData, CancelDeploymentData, CancelDeploymentResponse, GetDeploymentJobsData, GetDeploymentJobLogsData, TailDeploymentJobLogsData, GetDeploymentOperationsData, ExecuteDeploymentOperationData, ExecuteDeploymentOperationResponse, GetDeploymentOperationStatusData, PauseDeploymentData, PauseDeploymentResponse, PromoteDeploymentData, PromoteDeploymentResponse, ResumeDeploymentData, ResumeDeploymentResponse, RollbackToDeploymentData, RollbackToDeploymentResponse, TeardownDeploymentData, TeardownDeploymentResponse, ListDsnsData, CreateDsnData, CreateDsnResponse, GetOrCreateDsnData, GetOrCreateDsnResponse, RegenerateDsnData, RegenerateDsnResponse, RevokeDsnData, RevokeDsnResponse, GetEnvironmentVariablesData, CreateEnvironmentVariableData, CreateEnvironmentVariableResponse, GetResolvedEnvironmentVariablesData, GetResolvedEnvironmentVariableValueData, GetEnvironmentVariableValueData, DeleteEnvironmentVariableData, DeleteEnvironmentVariableResponse, UpdateEnvironmentVariableData, UpdateEnvironmentVariableResponse, GetEnvironmentsData, CreateEnvironmentData, CreateEnvironmentResponse, DeleteEnvironmentData, DeleteEnvironmentResponse, GetEnvironmentData, GetEnvironmentCronsData, GetCronByIdData, GetCronExecutionsData, GetCronExecutionsResponse, GetEnvironmentDomainsData, AddEnvironmentDomainData, AddEnvironmentDomainResponse, DeleteEnvironmentDomainData, DeleteEnvironmentDomainResponse, UpdateEnvironmentSettingsData, UpdateEnvironmentSettingsResponse, SleepEnvironmentData, SleepEnvironmentResponse, TeardownEnvironmentData, TeardownEnvironmentResponse, WakeEnvironmentData, WakeEnvironmentResponse, GetContainerLogsData, ListContainersData, GetContainerDetailData, GetContainerLogsByIdData, GetContainerMetricsData, StreamContainerMetricsData, RestartContainerData, RestartContainerResponse, StartContainerData, StartContainerResponse, StopContainerData, StopContainerResponse, DeployFromImageData, DeployFromImageResponse, DeployFromImageUploadData, DeployFromImageUploadResponse, DeployFromStaticData, DeployFromStaticResponse, ListAlertRulesData, CreateAlertRuleData, CreateAlertRuleResponse, DeleteAlertRuleData, DeleteAlertRuleResponse, GetAlertRuleData, UpdateAlertRuleData, UpdateAlertRuleResponse, GetErrorDashboardStatsData, ListErrorGroupsData, ListErrorGroupsResponse, GetErrorGroupData, UpdateErrorGroupData, ListErrorEventsData, ListErrorEventsResponse, GetErrorEventData, GetErrorStatsData, GetErrorTimeSeriesData, GetEventsCountData, GetEventTypeBreakdownData, RecordConsoleEventData, GetPropertyBreakdownData, GetPropertyTimelineData, GetEventsTimelineData, GetUniqueEventsData, GetUniqueEventsResponse, ListRemoteExternalImagesData, ListRemoteExternalImagesResponse, RegisterExternalImageData, RegisterExternalImageResponse, DeleteExternalImageData, DeleteExternalImageResponse, GetRemoteExternalImageData, ListFunnelsData, CreateFunnelData, CreateFunnelResponse2 as CreateFunnelResponse, PreviewFunnelMetricsData, PreviewFunnelMetricsResponse, DeleteFunnelData, UpdateFunnelData, GetFunnelMetricsData, UpdateGitSettingsData, UpdateGitSettingsResponse, HasErrorGroupsData, HasAnalyticsEventsData, GetHourlyVisitsData, ListExternalImagesData, PushExternalImageData, PushExternalImageResponse, GetExternalImageData, ListIncidentsData, CreateIncidentData, CreateIncidentResponse, GetBucketedIncidentsData, PurgeProjectLogsData, PurgeProjectLogsError, ListMcpsData, CreateMcpData, CreateMcpResponse, DeleteMcpData, DeleteMcpResponse, GetMcpData, UpdateMcpData, UpdateMcpResponse, ListMonitorsData, CreateMonitorData, CreateMonitorResponse, DeleteReleaseSourceMapsData, DeleteReleaseSourceMapsResponse, ListSourceMapsData, UploadSourceMapData, UploadSourceMapResponse, RevenueRecentEventsData, RevenueListIntegrationsData, RevenueCreateIntegrationData, RevenueCreateIntegrationResponse, RevenueDeleteIntegrationData, RevenueDeleteIntegrationResponse, RevenueUpdateConfigData, RevenueUpdateConfigResponse, RevenueImportInvoicesCsvData, RevenueImportInvoicesCsvResponse, RevenueImportSubscriptionsCsvData, RevenueImportSubscriptionsCsvResponse, RevenueRotateTokenData, RevenueRotateTokenResponse, RevenueUpdateSecretData, RevenueUpdateSecretResponse, RevenueMetricsCustomersData, RevenueMetricsMrrData, RevenueMetricsSummaryData, ListProjectSecretsData, CreateProjectSecretData, CreateProjectSecretResponse, DeleteProjectSecretData, DeleteProjectSecretResponse, UpdateProjectSecretData, UpdateProjectSecretResponse, UpdateProjectSettingsData, UpdateProjectSettingsResponse, ListSkillsData, CreateSkillData, CreateSkillResponse, UploadSkillData, UploadSkillResponse, DeleteSkillData, DeleteSkillResponse, GetSkillData, UpdateSkillData, UpdateSkillResponse, DownloadSkillArchiveData, ListReleasesData, DeleteSourceMapData, DeleteSourceMapResponse, ListStaticBundlesData, ListStaticBundlesResponse, DeleteStaticBundleData, DeleteStaticBundleResponse, GetStaticBundleData, GetStatusOverviewData, GetUniqueCountsData, UploadStaticBundleData, UploadStaticBundleResponse, ListProjectScansData, ListProjectScansError, ListProjectScansResponse, TriggerScanData, TriggerScanError, TriggerScanResponse2 as TriggerScanResponse, GetLatestScansPerEnvironmentData, GetLatestScanData, ListWebhooksData, ListWebhooksResponse, CreateWebhookData, CreateWebhookResponse, DeleteWebhookData, DeleteWebhookResponse, GetWebhookData, UpdateWebhookData, UpdateWebhookResponse, ListDeliveriesData, GetDeliveryData, RetryDeliveryData, RetryDeliveryResponse, WorkflowDryRunData, WorkflowDryRunResponse, ListMemoryData, WriteMemoryData, WriteMemoryResponse, SearchMemoryData, DropMemoryData, DropMemoryResponse, SupersedeMemoryData, SupersedeMemoryResponse, WorkspaceListSessionsData, WorkspaceListSessionsResponse, WorkspaceStartSessionData, WorkspaceStartSessionResponse, WorkspaceDeleteSessionData, WorkspaceDeleteSessionResponse, WorkspaceGetSessionData, WorkspaceUpdateSessionData, WorkspaceUpdateSessionResponse, WorkspaceCancelRunData, WorkspaceCancelRunResponse, WorkspaceCloseSessionData, WorkspaceCloseSessionResponse, WorkspaceSendMessageData, WorkspaceSendMessageResponse, WorkspaceRegeneratePreviewPasswordData, WorkspaceRegeneratePreviewPasswordResponse, WorkspaceReopenSessionData, WorkspaceReopenSessionResponse, WorkspaceRefreshSandboxData, WorkspaceRefreshSandboxResponse, WorkspaceRestartSandboxData, WorkspaceRestartSandboxResponse, WorkspaceStartSandboxData, WorkspaceStartSandboxResponse, WorkspaceSandboxStatsData, WorkspaceStopSandboxData, WorkspaceStopSandboxResponse, WorkspaceTerminalPasteImageData, WorkspaceTerminalPasteImageResponse, WorkspaceListTerminalTabsData, WorkspaceDeleteTerminalTabData, WorkspaceDeleteTerminalTabResponse, GetProxyLogsData, GetProxyLogsResponse, GetProxyLogByRequestIdData, GetProjectsHealthData, GetTimeBucketStatsData, GetTodayStatsData, GetProxyLogByIdData, ListSyncedRepositoriesData, ListSyncedRepositoriesResponse, GetRepositoryByNameData, GetAllRepositoriesByNameData, GetRepositoryPresetByNameData, GetRepositoryBranchesData, GetRepositoryTagsData, GetRepositoryPresetLiveData, GetRepositoryByIdData, GetBranchesByRepositoryIdData, ListCommitsByRepositoryIdData, CheckCommitExistsData, GetTagsByRepositoryIdData, GetRestoreRunData, RevenueGlobalEventsData, RevenueMetricsGlobalMrrData, RevenueMetricsGlobalSummaryData, RevenueListProvidersData, GetProjectSessionReplaysData, GetProjectSessionReplaysError, GetProjectSessionReplaysResponse2 as GetProjectSessionReplaysResponse, GetSessionEventsData, GetSettingsData, UpdateSettingsData, UpdateSettingsResponse, SaveAgentTokenData, SaveAgentTokenResponse2 as SaveAgentTokenResponse, ListAiProvidersData, UpdateAiProviderData, UpdateAiProviderResponse2 as UpdateAiProviderResponse, ActivateAiProviderData, ActivateAiProviderResponse, SaveAiProviderCredentialData, SaveAiProviderCredentialResponse, RevokeJoinTokenData, RevokeJoinTokenResponse, GenerateJoinTokenData, GenerateJoinTokenResponse2 as GenerateJoinTokenResponse, GetJoinTokenStatusData, ListGlobalMcpsData, CreateGlobalMcpData, CreateGlobalMcpResponse, DeleteGlobalMcpData, DeleteGlobalMcpResponse, GetGlobalMcpData, UpdateGlobalMcpData, UpdateGlobalMcpResponse, RefreshRouteTableData, RefreshRouteTableResponse, RebuildSandboxImageData, GetGlobalSandboxStatusData, ListSecretsData, UpsertSecretData, UpsertSecretResponse, DeleteSecretData, DeleteSecretResponse, ListGlobalSkillsData, CreateGlobalSkillData, CreateGlobalSkillResponse, UploadGlobalSkillData, UploadGlobalSkillResponse, DeleteGlobalSkillData, DeleteGlobalSkillResponse, GetGlobalSkillData, UpdateGlobalSkillData, UpdateGlobalSkillResponse, DownloadGlobalSkillArchiveData, ListProjectTemplatesData, ListProjectTemplateTagsData, GetProjectTemplateData, GetCurrentUserData, ListUsersData, CreateUserData, CreateUserResponse, UpdateSelfData, UpdateSelfResponse, DisableMfaData, DisableMfaResponse, SetupMfaData, SetupMfaResponse, VerifyAndEnableMfaData, VerifyAndEnableMfaResponse, DeleteUserData, DeleteUserResponse, UpdateUserData, UpdateUserResponse, RestoreUserData, RestoreUserResponse, AssignRoleData, RemoveRoleData, RemoveRoleResponse, ListSandboxesData, ListSandboxesResponse2 as ListSandboxesResponse, CreateSandboxData, CreateSandboxResponse, GetSandboxData, CmdData, CmdResponse2 as CmdResponse, GetCmdData, CmdLogsData, DestroySandboxData, DestroySandboxResponse, DomainData, ExecData, ExecResponse2 as ExecResponse, ExecDetachedData, ExecDetachedResponse2 as ExecDetachedResponse, ExtendTimeoutData, ExtendTimeoutResponse, MkdirData, MkdirResponse, ReadFileData, StatPathData, WriteFileData, WriteFileResponse, WriteFilesData, WriteFilesResponse2 as WriteFilesResponse, ListJobsData, JobStatusData, KillJobData, KillJobResponse, JobLogsData, PauseSandboxData, PauseSandboxResponse, ClearPreviewPasswordData, ClearPreviewPasswordResponse, SetPreviewPasswordData, SetPreviewPasswordResponse2 as SetPreviewPasswordResponse, RestartSandboxData, RestartSandboxResponse, ResumeSandboxData, ResumeSandboxResponse, SourceSandboxData, SourceSandboxResponse, StopSandboxData, StopSandboxResponse, CmdKillData, CmdKillResponse, GetVisitorSessionsData, GetVisitorSessionsError, GetVisitorSessionsResponse2 as GetVisitorSessionsResponse, DeleteSessionReplayData, DeleteSessionReplayError, GetSessionReplayData, UpdateSessionDurationData, UpdateSessionDurationError, UpdateSessionDurationResponse2 as UpdateSessionDurationResponse, GetSessionReplayEventsData, AddEventsData, AddEventsError, AddEventsResponse2 as AddEventsResponse, DeleteScanData, DeleteScanError, DeleteScanResponse, GetScanData, GetScanVulnerabilitiesData, GetScanVulnerabilitiesError, GetScanVulnerabilitiesResponse, ListEventTypesData, TriggerWeeklyDigestData, TriggerWeeklyDigestResponse, ListExternalPluginsData, ReloadPluginsData, ReloadPluginsResponse, IngestSentryEnvelopeData, IngestSentryEventData, IngestSentryEventResponse, ListAuditLogsData, ListAuditLogsResponse, GetAuditLogData } from '../types.gen'; +import type { GetPlatformInfoData, ChunkUploadOptionsData, CreateReleaseData, CreateReleaseResponse, CreateProjectReleaseData, CreateProjectReleaseResponse, FinalizeProjectReleaseData, FinalizeProjectReleaseResponse, ListReleaseFilesData, UploadReleaseFileData, UploadReleaseFileResponse, RecordEventMetricsData, RecordEventMetricsResponse, AddSessionReplayEventsData, AddSessionReplayEventsError, AddSessionReplayEventsResponse, InitSessionReplayData, InitSessionReplayError, InitSessionReplayResponse, RecordSpeedMetricsData, RecordSpeedMetricsError, RecordSpeedMetricsResponse, UpdateSpeedMetricsData, UpdateSpeedMetricsError, UpdateSpeedMetricsResponse, WebhookTriggerData, WebhookTriggerResponse2 as WebhookTriggerResponse, GetPricingData, ListProviderKeysData, CreateProviderKeyData, CreateProviderKeyError, CreateProviderKeyResponse, TestProviderKeyInlineData, TestProviderKeyInlineError, TestProviderKeyInlineResponse, DeleteProviderKeyData, DeleteProviderKeyError, DeleteProviderKeyResponse, UpdateProviderKeyData, UpdateProviderKeyError, UpdateProviderKeyResponse, TestProviderKeyByIdData, TestProviderKeyByIdError, TestProviderKeyByIdResponse, GetUsageByProviderData, GetConversationsData, GetConversationDetailData, GetUsageRecentData, GetUsageSummaryData, GetUsageTimeseriesData, GetUsageTopModelsData, ChatCompletionsData, ChatCompletionsError, ChatCompletionsResponse, EmbeddingsData, EmbeddingsError, EmbeddingsResponse, ListModelsData, GetAnalyticsActiveVisitorsData, GetEventDetailData, GetEventVisitorsData, GetEventVisitorsResponse, GetAnalyticsEventsCountData, GetGeneralStatsData, GetLiveVisitorsListData, GetPageFlowData, GetPageHourlySessionsData, GetPagePathDetailData, GetPagePathVisitorsData, GetPagePathVisitorsResponse, GetPagePathsData, GetPagePathsSparklinesData, GetRecentActivityData, GetSessionDetailsData, GetAnalyticsSessionEventsData, GetAnalyticsSessionEventsResponse, GetSessionLogsData, GetSessionLogsResponse, GetVisitorsData, GetVisitorsResponse, GetVisitorByGuidData, GetVisitorByIdData, GetVisitorDetailsData, EnrichVisitorData, EnrichVisitorResponse2 as EnrichVisitorResponse, GetVisitorInfoData, GetVisitorJourneyData, GetAnalyticsVisitorSessionsData, GetVisitorStatsData, ListApiKeysData, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyResponse2 as CreateApiKeyResponse, GetApiKeyPermissionsData, DeleteApiKeyData, DeleteApiKeyResponse, GetApiKeyData, UpdateApiKeyData, UpdateApiKeyResponse, ActivateApiKeyData, ActivateApiKeyResponse, DeactivateApiKeyData, DeactivateApiKeyResponse, CliLoginData, CliLoginResponse2 as CliLoginResponse, CliLogoutData, CliLogoutResponse, EmailStatusData, LoginData, LoginResponse, RequestMagicLinkData, RequestMagicLinkResponse, VerifyMagicLinkData, RequestPasswordResetData, RequestPasswordResetResponse, ResetPasswordData, ResetPasswordResponse, VerifyEmailData, VerifyMfaChallengeData, VerifyMfaChallengeResponse, RunExternalServiceBackupData, RunExternalServiceBackupError, RunExternalServiceBackupResponse, ListS3SourcesData, CreateS3SourceData, CreateS3SourceError, CreateS3SourceResponse, TestS3ConnectionPreviewData, TestS3ConnectionPreviewError, TestS3ConnectionPreviewResponse, DeleteS3SourceData, DeleteS3SourceError, DeleteS3SourceResponse, GetS3SourceData, UpdateS3SourceData, UpdateS3SourceError, UpdateS3SourceResponse, ListSourceBackupsData, RunBackupForSourceData, RunBackupForSourceError, RunBackupForSourceResponse, SetDefaultS3SourceData, SetDefaultS3SourceError, SetDefaultS3SourceResponse, TestS3SourceConnectionData, TestS3SourceConnectionError, TestS3SourceConnectionResponse, ListBackupSchedulesData, CreateBackupScheduleData, CreateBackupScheduleError, CreateBackupScheduleResponse, DeleteBackupScheduleData, DeleteBackupScheduleError, DeleteBackupScheduleResponse, GetBackupScheduleData, ListBackupsForScheduleData, DisableBackupScheduleData, DisableBackupScheduleResponse, EnableBackupScheduleData, EnableBackupScheduleResponse, GetBackupData, BlobDeleteData, BlobDeleteError, BlobDeleteResponse, BlobListData, BlobListError, BlobListResponse, BlobPutData, BlobPutError, BlobPutResponse, BlobCopyData, BlobCopyError, BlobCopyResponse, BlobDisableData, BlobDisableResponse, BlobEnableData, BlobEnableResponse, BlobStatusData, BlobUpdateData, BlobUpdateResponse, BlobDownloadData, GetDashboardProjectsAnalyticsData, GetActivityGraphData, GetScanByDeploymentData, ListDnsProvidersData, CreateDnsProviderData, CreateDnsProviderResponse, DeleteDnsProviderData, DeleteDnsProviderResponse, GetDnsProviderData, UpdateProviderData, UpdateProviderResponse, ListManagedDomainsData, AddManagedDomainData, AddManagedDomainResponse, TestProviderConnectionData, TestProviderConnectionResponse, ListProviderZonesData, RemoveManagedDomainData, RemoveManagedDomainResponse, VerifyManagedDomainData, VerifyManagedDomainResponse, LookupDnsARecordsData, ListDomainsData, ListDomainsResponse2 as ListDomainsResponse, CreateDomainData, CreateDomainResponse, GetDomainByHostData, CancelDomainOrderData, CancelDomainOrderResponse, GetDomainOrderData, CreateOrRecreateOrderData, CreateOrRecreateOrderResponse, FinalizeOrderData, FinalizeOrderResponse, SetupDnsChallengeData, SetupDnsChallengeResponse2 as SetupDnsChallengeResponse, DeleteDomainData, DeleteDomainResponse, GetDomainByIdData, GetChallengeTokenData, GetHttpChallengeDebugData, ProvisionDomainData, ProvisionDomainResponse, RenewDomainData, RenewDomainResponse, CheckDomainStatusData, ListEmailDomainsData, CreateEmailDomainData, CreateEmailDomainResponse, GetDomainByNameData, DeleteEmailDomainData, DeleteEmailDomainResponse, GetDomainData, GetDomainDnsRecordsData, SetupDnsData, SetupDnsResponse2 as SetupDnsResponse, VerifyDomainData, VerifyDomainResponse, ListEmailProvidersData, CreateEmailProviderData, CreateEmailProviderResponse, DeleteEmailProviderData, DeleteEmailProviderResponse, GetEmailProviderData, TestProviderData, TestProviderResponse2 as TestProviderResponse, ListEmailsData, ListEmailsResponse, SendEmailData, SendEmailResponse, GetGlobalEventsData, GetGlobalEventsResponse, GetGlobalEventStatsData, GetEmailStatsData, ValidateEmailData, ValidateEmailResponse2 as ValidateEmailResponse, TrackClickData, TrackOpenData, GetEmailData, GetEmailTrackingData, GetEmailEventsData, GetEmailLinksData, ListServicesData, ListServicesResponse, CreateServiceData, CreateServiceResponse, ListAvailableContainersData, GetServiceBySlugData, ListServiceHealthStatusesData, ImportExternalServiceData, ImportExternalServiceResponse, ListProjectServicesData, ListProjectServicesResponse, GetProjectServiceEnvironmentVariablesData, GetProvidersMetadataData, GetProviderMetadataData, GetServiceTypesData, GetServiceTypeParametersData, DeleteServiceData, DeleteServiceResponse, GetServiceData, UpdateServiceData, UpdateServiceResponse, GetClusterHealthData, TriggerServiceHealthCheckData, TriggerServiceHealthCheckResponse, GetServiceHealthStatusData, AddClusterMemberData, AddClusterMemberResponse, RemoveClusterMemberData, RemoveClusterMemberResponse, GetClusterMemberData, PromoteClusterMemberData, GetServicePreviewEnvironmentVariablesMaskedData, GetServicePreviewEnvironmentVariableNamesData, ListServiceProjectsData, ListServiceProjectsResponse, LinkServiceToProjectData, LinkServiceToProjectResponse, UnlinkServiceFromProjectData, UnlinkServiceFromProjectResponse, GetServiceEnvironmentVariablesData, GetServiceEnvironmentVariableData, StartRestoreData, StartRestoreError, StartRestoreResponse, GetRestoreCapabilitiesData, PlanRestoreData, PlanRestoreError, PlanRestoreResponse, ListRestoreRunsForServiceData, RetryClusterData, RetryClusterResponse, StartServiceData, StartServiceResponse, StopServiceData, StopServiceResponse, UpgradeServiceData, UpgradeServiceResponse, ListRootContainersData, ListContainersAtPathData, ListEntitiesData, GetEntityInfoData, QueryDataData, QueryDataResponse2 as QueryDataResponse, DownloadObjectData, GetContainerInfoData, CheckExplorerSupportData, ListPgUpgradesData, StartPgUpgradeData, StartPgUpgradeResponse, GetPgUpgradeData, CancelPgUpgradeData, CancelPgUpgradeResponse, GetPgUpgradeLogsData, RetryPgUpgradeData, RetryPgUpgradeResponse, RollbackPgUpgradeData, RollbackPgUpgradeResponse, GetFileData, GetIpGeolocationData, ListConnectionsData, ListConnectionsResponse, DeleteConnectionData, DeleteConnectionResponse, ActivateConnectionData, DeactivateConnectionData, RunConnectionHealthCheckData, RunConnectionHealthCheckResponse, ListRepositoriesByConnectionData, ListRepositoriesByConnectionResponse, SyncRepositoriesData, SyncRepositoriesResponse, UpdateConnectionTokenData, UpdateConnectionTokenResponse, ValidateConnectionData, ListGitProvidersData, CreateGitProviderData, CreateGitProviderResponse, CreateGithubPatProviderData, CreateGithubPatProviderResponse, CreateGitlabOauthProviderData, CreateGitlabOauthProviderResponse, CreateGitlabPatProviderData, CreateGitlabPatProviderResponse, DeleteGitProviderData, DeleteGitProviderResponse, GetGitProviderData, ActivateProviderData, HandleGitProviderOauthCallbackData, GetProviderConnectionsData, UpdateGitProviderCredentialsData, UpdateGitProviderCredentialsResponse, DeactivateProviderData, CheckProviderDeletionSafetyData, StartGitProviderOauthData, DeleteProviderSafelyData, DeleteProviderSafelyResponse, GetPublicRepositoryData, GetPublicBranchesData, DetectPublicPresetsData, DiscoverWorkloadsData, DiscoverWorkloadsResponse, ExecuteImportData, ExecuteImportResponse2 as ExecuteImportResponse, CreatePlanData, CreatePlanResponse2 as CreatePlanResponse, ListSourcesData, GetImportStatusData, GetIncidentData, UpdateIncidentStatusData, UpdateIncidentStatusResponse, GetIncidentUpdatesData, AdminListNodesData, RegisterNodeData, RegisterNodeResponse2 as RegisterNodeResponse, AdminRemoveNodeData, AdminRemoveNodeResponse, AdminGetNodeData, AdminListNodeContainersData, PostDnsAckData, PostDnsAckResponse, GetDnsChangesData, AdminUndrainNodeData, AdminUndrainNodeResponse, AdminDrainStatusData, AdminDrainNodeData, AdminDrainNodeResponse, NodeHeartbeatData, NodeHeartbeatResponse, ListPeersData, GetS3CredentialsData, ListIpAccessControlData, CreateIpAccessControlData, CreateIpAccessControlError, CreateIpAccessControlResponse, CheckIpBlockedData, DeleteIpAccessControlData, DeleteIpAccessControlError, DeleteIpAccessControlResponse, GetIpAccessControlData, UpdateIpAccessControlData, UpdateIpAccessControlError, UpdateIpAccessControlResponse, KvDelData, KvDelResponse, KvDisableData, KvDisableResponse, KvEnableData, KvEnableResponse, KvExpireData, KvExpireResponse, KvGetData, KvGetResponse, KvIncrData, KvIncrResponse, KvKeysData, KvKeysResponse, KvSetData, KvSetResponse, KvStatusData, KvTtlData, KvTtlResponse, KvUpdateData, KvUpdateResponse, ListRoutesData, CreateRouteData, CreateRouteResponse, DeleteRouteData, DeleteRouteResponse, GetRouteData, UpdateRouteData, UpdateRouteResponse, LogoutData, GetLogContextData, SearchLogsData, SearchLogsError, SearchLogsResponse2 as SearchLogsResponse, TailLogsData, GetProjectsMonitorHealthData, DeleteMonitorData, DeleteMonitorResponse, GetMonitorData, GetBucketedStatusData, GetCurrentMonitorStatusData, GetUptimeHistoryData, DeletePreferencesData, DeletePreferencesResponse, GetPreferencesData, UpdatePreferencesData, UpdatePreferencesResponse, ListNotificationProvidersData, ListNotificationProvidersResponse, CreateNotificationProviderData, CreateNotificationProviderResponse, CreateNotificationEmailProviderData, CreateNotificationEmailProviderResponse, UpdateEmailProviderData, UpdateEmailProviderResponse, CreateSlackProviderData, CreateSlackProviderResponse, UpdateSlackProviderData, UpdateSlackProviderResponse, CreateWebhookProviderData, CreateWebhookProviderResponse, UpdateWebhookProviderData, UpdateWebhookProviderResponse, DeleteNotificationProviderData, DeleteNotificationProviderResponse, GetNotificationProviderData, UpdateNotificationProviderData, UpdateNotificationProviderResponse, TestNotificationProviderData, TestNotificationProviderResponse, ListOrdersData, ListOrdersResponse2 as ListOrdersResponse, QueryGenaiTracesData, QueryGenaiTracesError, QueryGenaiTracesResponse, GetGenaiTraceData, GetHealthData, ListInsightsData, ListInsightsError, ListInsightsResponse, QueryLogsData, QueryLogsError, QueryLogsResponse, ListMetricNamesData, QueryMetricsData, GetPipelineStatsData, GetQuotaData, QueryTraceSummariesData, QueryTraceSummariesError, QueryTraceSummariesResponse, QueryTracesData, QueryTracesError, QueryTracesResponse, GetTraceData, IngestLogsData, IngestLogsError, IngestMetricsData, IngestMetricsError, IngestTracesData, IngestTracesError, IngestLogsByPathData, IngestLogsByPathError, IngestMetricsByPathData, IngestMetricsByPathError, IngestTracesByPathData, IngestTracesByPathError, HasPerformanceMetricsData, GetPerformanceMetricsData, GetMetricsOverTimeData, GetGroupedPageMetricsData, GetAccessInfoData, GetPrivateIpData, GetPublicIpData, ListPresetsData, GeneratePresetDockerfileData, GeneratePresetDockerfileResponse, GetPreviewGatewayLogsData, RestartPreviewGatewayData, RestartPreviewGatewayResponse, GetPreviewGatewaySettingsData, PatchPreviewGatewaySettingsData, PatchPreviewGatewaySettingsResponse, GetPreviewGatewayStatusData, UpgradePreviewGatewayData, UpgradePreviewGatewayResponse, GetProjectsData, GetProjectsResponse, CreateProjectData, CreateProjectResponse, GetProjectBySlugData, CreateProjectFromTemplateData, CreateProjectFromTemplateResponse2 as CreateProjectFromTemplateResponse, GetProjectStatisticsData, DeleteProjectData, DeleteProjectResponse, GetProjectData, UpdateProjectData, UpdateProjectResponse, GetProjectDeploymentsData, GetProjectDeploymentsResponse, GetLastDeploymentData, TriggerProjectPipelineData, TriggerProjectPipelineResponse, GetActiveVisitorsData, ListAgentsData, CreateAgentData, CreateAgentResponse, GetCliStatusData, ListAllRunsData, ListAllRunsResponse, LatestRunForSourceData, GetRunWithLogsData, CancelRunData, CancelRunResponse, RetryRunData, RetryRunResponse, GetSandboxStatusData, SmokeTestAgentData, SmokeTestAgentResponse, DeleteAgentData, DeleteAgentResponse, GetAgentData, UpdateAgentData, UpdateAgentResponse, ListAgentRunsData, ListAgentRunsResponse, TriggerAgentData, TriggerAgentResponse, GetAggregatedBucketsData, StartAnalysisData, StartAnalysisResponse, GetRunData, AddContextData, CancelData, CreatePrData, CreatePrResponse2 as CreatePrResponse, StartFixData, ReAnalyzeData, UpdateAutomaticDeployData, UpdateAutomaticDeployResponse, ListCustomDomainsForProjectData, CreateCustomDomainData, CreateCustomDomainResponse, DeleteCustomDomainData, DeleteCustomDomainResponse, GetCustomDomainData, UpdateCustomDomainData, UpdateCustomDomainResponse, LinkCustomDomainToCertificateData, LinkCustomDomainToCertificateResponse, UpdateProjectDeploymentConfigData, UpdateProjectDeploymentConfigResponse, GetDeploymentData, CancelDeploymentData, CancelDeploymentResponse, GetDeploymentJobsData, GetDeploymentJobLogsData, TailDeploymentJobLogsData, GetDeploymentOperationsData, ExecuteDeploymentOperationData, ExecuteDeploymentOperationResponse, GetDeploymentOperationStatusData, PauseDeploymentData, PauseDeploymentResponse, PromoteDeploymentData, PromoteDeploymentResponse, ResumeDeploymentData, ResumeDeploymentResponse, RollbackToDeploymentData, RollbackToDeploymentResponse, TeardownDeploymentData, TeardownDeploymentResponse, ListDsnsData, CreateDsnData, CreateDsnResponse, GetOrCreateDsnData, GetOrCreateDsnResponse, RegenerateDsnData, RegenerateDsnResponse, RevokeDsnData, RevokeDsnResponse, GetEnvironmentVariablesData, CreateEnvironmentVariableData, CreateEnvironmentVariableResponse, GetResolvedEnvironmentVariablesData, GetResolvedEnvironmentVariableValueData, GetEnvironmentVariableValueData, DeleteEnvironmentVariableData, DeleteEnvironmentVariableResponse, UpdateEnvironmentVariableData, UpdateEnvironmentVariableResponse, GetEnvironmentsData, CreateEnvironmentData, CreateEnvironmentResponse, DeleteEnvironmentData, DeleteEnvironmentResponse, GetEnvironmentData, GetEnvironmentCronsData, GetCronByIdData, GetCronExecutionsData, GetCronExecutionsResponse, GetEnvironmentDomainsData, AddEnvironmentDomainData, AddEnvironmentDomainResponse, DeleteEnvironmentDomainData, DeleteEnvironmentDomainResponse, UpdateEnvironmentSettingsData, UpdateEnvironmentSettingsResponse, SleepEnvironmentData, SleepEnvironmentResponse, TeardownEnvironmentData, TeardownEnvironmentResponse, WakeEnvironmentData, WakeEnvironmentResponse, GetContainerLogsData, ListContainersData, GetContainerDetailData, GetContainerLogsByIdData, GetContainerMetricsData, StreamContainerMetricsData, RestartContainerData, RestartContainerResponse, StartContainerData, StartContainerResponse, StopContainerData, StopContainerResponse, DeployFromImageData, DeployFromImageResponse, DeployFromImageUploadData, DeployFromImageUploadResponse, DeployFromStaticData, DeployFromStaticResponse, ListAlertRulesData, CreateAlertRuleData, CreateAlertRuleResponse, DeleteAlertRuleData, DeleteAlertRuleResponse, GetAlertRuleData, UpdateAlertRuleData, UpdateAlertRuleResponse, GetErrorDashboardStatsData, ListErrorGroupsData, ListErrorGroupsResponse, GetErrorGroupData, UpdateErrorGroupData, ListErrorEventsData, ListErrorEventsResponse, GetErrorEventData, GetErrorStatsData, GetErrorTimeSeriesData, GetEventsCountData, GetEventTypeBreakdownData, RecordConsoleEventData, GetPropertyBreakdownData, GetPropertyTimelineData, GetEventsTimelineData, GetUniqueEventsData, GetUniqueEventsResponse, ListRemoteExternalImagesData, ListRemoteExternalImagesResponse, RegisterExternalImageData, RegisterExternalImageResponse, DeleteExternalImageData, DeleteExternalImageResponse, GetRemoteExternalImageData, ListFunnelsData, CreateFunnelData, CreateFunnelResponse2 as CreateFunnelResponse, PreviewFunnelMetricsData, PreviewFunnelMetricsResponse, DeleteFunnelData, UpdateFunnelData, GetFunnelMetricsData, UpdateGitSettingsData, UpdateGitSettingsResponse, ReinstallGitlabWebhookData, ReinstallGitlabWebhookResponse, HasErrorGroupsData, HasAnalyticsEventsData, GetHourlyVisitsData, ListExternalImagesData, PushExternalImageData, PushExternalImageResponse, GetExternalImageData, ListIncidentsData, CreateIncidentData, CreateIncidentResponse, GetBucketedIncidentsData, PurgeProjectLogsData, PurgeProjectLogsError, ListMcpsData, CreateMcpData, CreateMcpResponse, DeleteMcpData, DeleteMcpResponse, GetMcpData, UpdateMcpData, UpdateMcpResponse, ListMonitorsData, CreateMonitorData, CreateMonitorResponse, DeleteReleaseSourceMapsData, DeleteReleaseSourceMapsResponse, ListSourceMapsData, UploadSourceMapData, UploadSourceMapResponse, RevenueRecentEventsData, RevenueListIntegrationsData, RevenueCreateIntegrationData, RevenueCreateIntegrationResponse, RevenueDeleteIntegrationData, RevenueDeleteIntegrationResponse, RevenueUpdateConfigData, RevenueUpdateConfigResponse, RevenueImportInvoicesCsvData, RevenueImportInvoicesCsvResponse, RevenueImportSubscriptionsCsvData, RevenueImportSubscriptionsCsvResponse, RevenueRotateTokenData, RevenueRotateTokenResponse, RevenueUpdateSecretData, RevenueUpdateSecretResponse, RevenueMetricsCustomersData, RevenueMetricsMrrData, RevenueMetricsSummaryData, ListProjectSecretsData, CreateProjectSecretData, CreateProjectSecretResponse, DeleteProjectSecretData, DeleteProjectSecretResponse, UpdateProjectSecretData, UpdateProjectSecretResponse, UpdateProjectSettingsData, UpdateProjectSettingsResponse, ListSkillsData, CreateSkillData, CreateSkillResponse, UploadSkillData, UploadSkillResponse, DeleteSkillData, DeleteSkillResponse, GetSkillData, UpdateSkillData, UpdateSkillResponse, DownloadSkillArchiveData, ListReleasesData, DeleteSourceMapData, DeleteSourceMapResponse, ListStaticBundlesData, ListStaticBundlesResponse, DeleteStaticBundleData, DeleteStaticBundleResponse, GetStaticBundleData, GetStatusOverviewData, GetUniqueCountsData, UploadStaticBundleData, UploadStaticBundleResponse, ListProjectScansData, ListProjectScansError, ListProjectScansResponse, TriggerScanData, TriggerScanError, TriggerScanResponse2 as TriggerScanResponse, GetLatestScansPerEnvironmentData, GetLatestScanData, ListWebhooksData, ListWebhooksResponse, CreateWebhookData, CreateWebhookResponse, DeleteWebhookData, DeleteWebhookResponse, GetWebhookData, UpdateWebhookData, UpdateWebhookResponse, ListDeliveriesData, GetDeliveryData, RetryDeliveryData, RetryDeliveryResponse, WorkflowDryRunData, WorkflowDryRunResponse, ListMemoryData, WriteMemoryData, WriteMemoryResponse, SearchMemoryData, DropMemoryData, DropMemoryResponse, SupersedeMemoryData, SupersedeMemoryResponse, WorkspaceListSessionsData, WorkspaceListSessionsResponse, WorkspaceStartSessionData, WorkspaceStartSessionResponse, WorkspaceDeleteSessionData, WorkspaceDeleteSessionResponse, WorkspaceGetSessionData, WorkspaceUpdateSessionData, WorkspaceUpdateSessionResponse, WorkspaceCancelRunData, WorkspaceCancelRunResponse, WorkspaceCloseSessionData, WorkspaceCloseSessionResponse, WorkspaceSendMessageData, WorkspaceSendMessageResponse, WorkspaceRegeneratePreviewPasswordData, WorkspaceRegeneratePreviewPasswordResponse, WorkspaceReopenSessionData, WorkspaceReopenSessionResponse, WorkspaceRefreshSandboxData, WorkspaceRefreshSandboxResponse, WorkspaceRestartSandboxData, WorkspaceRestartSandboxResponse, WorkspaceStartSandboxData, WorkspaceStartSandboxResponse, WorkspaceSandboxStatsData, WorkspaceStopSandboxData, WorkspaceStopSandboxResponse, WorkspaceTerminalPasteImageData, WorkspaceTerminalPasteImageResponse, WorkspaceListTerminalTabsData, WorkspaceDeleteTerminalTabData, WorkspaceDeleteTerminalTabResponse, GetProxyLogsData, GetProxyLogsResponse, GetProxyLogByRequestIdData, GetProjectsHealthData, GetTimeBucketStatsData, GetTodayStatsData, GetProxyLogByIdData, ListSyncedRepositoriesData, ListSyncedRepositoriesResponse, GetRepositoryByNameData, GetAllRepositoriesByNameData, GetRepositoryPresetByNameData, GetRepositoryBranchesData, GetRepositoryTagsData, GetRepositoryPresetLiveData, GetRepositoryByIdData, GetBranchesByRepositoryIdData, ListCommitsByRepositoryIdData, CheckCommitExistsData, GetTagsByRepositoryIdData, GetRestoreRunData, RevenueGlobalEventsData, RevenueMetricsGlobalMrrData, RevenueMetricsGlobalSummaryData, RevenueListProvidersData, GetProjectSessionReplaysData, GetProjectSessionReplaysError, GetProjectSessionReplaysResponse2 as GetProjectSessionReplaysResponse, GetSessionEventsData, GetSettingsData, UpdateSettingsData, UpdateSettingsResponse, SaveAgentTokenData, SaveAgentTokenResponse2 as SaveAgentTokenResponse, ListAiProvidersData, UpdateAiProviderData, UpdateAiProviderResponse2 as UpdateAiProviderResponse, ActivateAiProviderData, ActivateAiProviderResponse, SaveAiProviderCredentialData, SaveAiProviderCredentialResponse, RevokeJoinTokenData, RevokeJoinTokenResponse, GenerateJoinTokenData, GenerateJoinTokenResponse2 as GenerateJoinTokenResponse, GetJoinTokenStatusData, ListGlobalMcpsData, CreateGlobalMcpData, CreateGlobalMcpResponse, DeleteGlobalMcpData, DeleteGlobalMcpResponse, GetGlobalMcpData, UpdateGlobalMcpData, UpdateGlobalMcpResponse, RefreshRouteTableData, RefreshRouteTableResponse, RebuildSandboxImageData, GetGlobalSandboxStatusData, ListSecretsData, UpsertSecretData, UpsertSecretResponse, DeleteSecretData, DeleteSecretResponse, ListGlobalSkillsData, CreateGlobalSkillData, CreateGlobalSkillResponse, UploadGlobalSkillData, UploadGlobalSkillResponse, DeleteGlobalSkillData, DeleteGlobalSkillResponse, GetGlobalSkillData, UpdateGlobalSkillData, UpdateGlobalSkillResponse, DownloadGlobalSkillArchiveData, ListProjectTemplatesData, ListProjectTemplateTagsData, GetProjectTemplateData, GetCurrentUserData, ListUsersData, CreateUserData, CreateUserResponse, UpdateSelfData, UpdateSelfResponse, DisableMfaData, DisableMfaResponse, SetupMfaData, SetupMfaResponse, VerifyAndEnableMfaData, VerifyAndEnableMfaResponse, DeleteUserData, DeleteUserResponse, UpdateUserData, UpdateUserResponse, RestoreUserData, RestoreUserResponse, AssignRoleData, RemoveRoleData, RemoveRoleResponse, ListSandboxesData, ListSandboxesResponse2 as ListSandboxesResponse, CreateSandboxData, CreateSandboxResponse, GetSandboxData, CmdData, CmdResponse2 as CmdResponse, GetCmdData, CmdLogsData, DestroySandboxData, DestroySandboxResponse, DomainData, ExecData, ExecResponse2 as ExecResponse, ExecDetachedData, ExecDetachedResponse2 as ExecDetachedResponse, ExtendTimeoutData, ExtendTimeoutResponse, MkdirData, MkdirResponse, ReadFileData, StatPathData, WriteFileData, WriteFileResponse, WriteFilesData, WriteFilesResponse2 as WriteFilesResponse, ListJobsData, JobStatusData, KillJobData, KillJobResponse, JobLogsData, PauseSandboxData, PauseSandboxResponse, ClearPreviewPasswordData, ClearPreviewPasswordResponse, SetPreviewPasswordData, SetPreviewPasswordResponse2 as SetPreviewPasswordResponse, RestartSandboxData, RestartSandboxResponse, ResumeSandboxData, ResumeSandboxResponse, SourceSandboxData, SourceSandboxResponse, StopSandboxData, StopSandboxResponse, CmdKillData, CmdKillResponse, GetVisitorSessionsData, GetVisitorSessionsError, GetVisitorSessionsResponse2 as GetVisitorSessionsResponse, DeleteSessionReplayData, DeleteSessionReplayError, GetSessionReplayData, UpdateSessionDurationData, UpdateSessionDurationError, UpdateSessionDurationResponse2 as UpdateSessionDurationResponse, GetSessionReplayEventsData, AddEventsData, AddEventsError, AddEventsResponse2 as AddEventsResponse, DeleteScanData, DeleteScanError, DeleteScanResponse, GetScanData, GetScanVulnerabilitiesData, GetScanVulnerabilitiesError, GetScanVulnerabilitiesResponse, ListEventTypesData, TriggerWeeklyDigestData, TriggerWeeklyDigestResponse, ListExternalPluginsData, ReloadPluginsData, ReloadPluginsResponse, IngestSentryEnvelopeData, IngestSentryEventData, IngestSentryEventResponse, ListAuditLogsData, ListAuditLogsResponse, GetAuditLogData } from '../types.gen'; import { client } from '../client.gen'; export type QueryKey = [ @@ -1354,6 +1354,34 @@ export const deactivateApiKeyMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await cliLogin({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const cliLogoutMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await cliLogout({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const emailStatusQueryKey = (options?: Options) => createQueryKey('emailStatus', options); export const emailStatusOptions = (options?: Options) => { @@ -9508,6 +9536,26 @@ export const updateGitSettingsMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await reinstallGitlabWebhook({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const hasErrorGroupsQueryKey = (options: Options) => createQueryKey('hasErrorGroups', options); /** diff --git a/web/src/api/client/sdk.gen.ts b/web/src/api/client/sdk.gen.ts index 775676d87..e99b07e7b 100644 --- a/web/src/api/client/sdk.gen.ts +++ b/web/src/api/client/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { type Options as ClientOptions, type Client, type TDataShape, formDataBodySerializer } from './client'; -import type { GetPlatformInfoData, GetPlatformInfoResponses, GetPlatformInfoErrors, ChunkUploadOptionsData, ChunkUploadOptionsResponses, CreateReleaseData, CreateReleaseResponses, CreateReleaseErrors, CreateProjectReleaseData, CreateProjectReleaseResponses, CreateProjectReleaseErrors, FinalizeProjectReleaseData, FinalizeProjectReleaseResponses, FinalizeProjectReleaseErrors, ListReleaseFilesData, ListReleaseFilesResponses, ListReleaseFilesErrors, UploadReleaseFileData, UploadReleaseFileResponses, UploadReleaseFileErrors, RecordEventMetricsData, RecordEventMetricsResponses, RecordEventMetricsErrors, AddSessionReplayEventsData, AddSessionReplayEventsResponses, AddSessionReplayEventsErrors, InitSessionReplayData, InitSessionReplayResponses, InitSessionReplayErrors, RecordSpeedMetricsData, RecordSpeedMetricsResponses, RecordSpeedMetricsErrors, UpdateSpeedMetricsData, UpdateSpeedMetricsResponses, UpdateSpeedMetricsErrors, WebhookTriggerData, WebhookTriggerResponses, WebhookTriggerErrors, GetPricingData, GetPricingResponses, GetPricingErrors, ListProviderKeysData, ListProviderKeysResponses, ListProviderKeysErrors, CreateProviderKeyData, CreateProviderKeyResponses, CreateProviderKeyErrors, TestProviderKeyInlineData, TestProviderKeyInlineResponses, TestProviderKeyInlineErrors, DeleteProviderKeyData, DeleteProviderKeyResponses, DeleteProviderKeyErrors, UpdateProviderKeyData, UpdateProviderKeyResponses, UpdateProviderKeyErrors, TestProviderKeyByIdData, TestProviderKeyByIdResponses, TestProviderKeyByIdErrors, GetUsageByProviderData, GetUsageByProviderResponses, GetUsageByProviderErrors, GetConversationsData, GetConversationsResponses, GetConversationsErrors, GetConversationDetailData, GetConversationDetailResponses, GetConversationDetailErrors, GetUsageRecentData, GetUsageRecentResponses, GetUsageRecentErrors, GetUsageSummaryData, GetUsageSummaryResponses, GetUsageSummaryErrors, GetUsageTimeseriesData, GetUsageTimeseriesResponses, GetUsageTimeseriesErrors, GetUsageTopModelsData, GetUsageTopModelsResponses, GetUsageTopModelsErrors, ChatCompletionsData, ChatCompletionsResponses, ChatCompletionsErrors, EmbeddingsData, EmbeddingsResponses, EmbeddingsErrors, ListModelsData, ListModelsResponses, ListModelsErrors, GetAnalyticsActiveVisitorsData, GetAnalyticsActiveVisitorsResponses, GetAnalyticsActiveVisitorsErrors, GetEventDetailData, GetEventDetailResponses, GetEventDetailErrors, GetEventVisitorsData, GetEventVisitorsResponses, GetEventVisitorsErrors, GetAnalyticsEventsCountData, GetAnalyticsEventsCountResponses, GetAnalyticsEventsCountErrors, GetGeneralStatsData, GetGeneralStatsResponses, GetGeneralStatsErrors, GetLiveVisitorsListData, GetLiveVisitorsListResponses, GetLiveVisitorsListErrors, GetPageFlowData, GetPageFlowResponses, GetPageFlowErrors, GetPageHourlySessionsData, GetPageHourlySessionsResponses, GetPageHourlySessionsErrors, GetPagePathDetailData, GetPagePathDetailResponses, GetPagePathDetailErrors, GetPagePathVisitorsData, GetPagePathVisitorsResponses, GetPagePathVisitorsErrors, GetPagePathsData, GetPagePathsResponses, GetPagePathsErrors, GetPagePathsSparklinesData, GetPagePathsSparklinesResponses, GetPagePathsSparklinesErrors, GetRecentActivityData, GetRecentActivityResponses, GetRecentActivityErrors, GetSessionDetailsData, GetSessionDetailsResponses, GetSessionDetailsErrors, GetAnalyticsSessionEventsData, GetAnalyticsSessionEventsResponses, GetAnalyticsSessionEventsErrors, GetSessionLogsData, GetSessionLogsResponses, GetSessionLogsErrors, GetVisitorsData, GetVisitorsResponses, GetVisitorsErrors, GetVisitorByGuidData, GetVisitorByGuidResponses, GetVisitorByGuidErrors, GetVisitorByIdData, GetVisitorByIdResponses, GetVisitorByIdErrors, GetVisitorDetailsData, GetVisitorDetailsResponses, GetVisitorDetailsErrors, EnrichVisitorData, EnrichVisitorResponses, EnrichVisitorErrors, GetVisitorInfoData, GetVisitorInfoResponses, GetVisitorInfoErrors, GetVisitorJourneyData, GetVisitorJourneyResponses, GetVisitorJourneyErrors, GetAnalyticsVisitorSessionsData, GetAnalyticsVisitorSessionsResponses, GetAnalyticsVisitorSessionsErrors, GetVisitorStatsData, GetVisitorStatsResponses, GetVisitorStatsErrors, ListApiKeysData, ListApiKeysResponses, ListApiKeysErrors, CreateApiKeyData, CreateApiKeyResponses, CreateApiKeyErrors, GetApiKeyPermissionsData, GetApiKeyPermissionsResponses, GetApiKeyPermissionsErrors, DeleteApiKeyData, DeleteApiKeyResponses, DeleteApiKeyErrors, GetApiKeyData, GetApiKeyResponses, GetApiKeyErrors, UpdateApiKeyData, UpdateApiKeyResponses, UpdateApiKeyErrors, ActivateApiKeyData, ActivateApiKeyResponses, ActivateApiKeyErrors, DeactivateApiKeyData, DeactivateApiKeyResponses, DeactivateApiKeyErrors, EmailStatusData, EmailStatusResponses, EmailStatusErrors, LoginData, LoginResponses, LoginErrors, RequestMagicLinkData, RequestMagicLinkResponses, RequestMagicLinkErrors, VerifyMagicLinkData, VerifyMagicLinkResponses, VerifyMagicLinkErrors, RequestPasswordResetData, RequestPasswordResetResponses, RequestPasswordResetErrors, ResetPasswordData, ResetPasswordResponses, ResetPasswordErrors, VerifyEmailData, VerifyEmailResponses, VerifyEmailErrors, VerifyMfaChallengeData, VerifyMfaChallengeResponses, VerifyMfaChallengeErrors, RunExternalServiceBackupData, RunExternalServiceBackupResponses, RunExternalServiceBackupErrors, ListS3SourcesData, ListS3SourcesResponses, ListS3SourcesErrors, CreateS3SourceData, CreateS3SourceResponses, CreateS3SourceErrors, TestS3ConnectionPreviewData, TestS3ConnectionPreviewResponses, TestS3ConnectionPreviewErrors, DeleteS3SourceData, DeleteS3SourceResponses, DeleteS3SourceErrors, GetS3SourceData, GetS3SourceResponses, GetS3SourceErrors, UpdateS3SourceData, UpdateS3SourceResponses, UpdateS3SourceErrors, ListSourceBackupsData, ListSourceBackupsResponses, ListSourceBackupsErrors, RunBackupForSourceData, RunBackupForSourceResponses, RunBackupForSourceErrors, SetDefaultS3SourceData, SetDefaultS3SourceResponses, SetDefaultS3SourceErrors, TestS3SourceConnectionData, TestS3SourceConnectionResponses, TestS3SourceConnectionErrors, ListBackupSchedulesData, ListBackupSchedulesResponses, ListBackupSchedulesErrors, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateBackupScheduleErrors, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteBackupScheduleErrors, GetBackupScheduleData, GetBackupScheduleResponses, GetBackupScheduleErrors, ListBackupsForScheduleData, ListBackupsForScheduleResponses, ListBackupsForScheduleErrors, DisableBackupScheduleData, DisableBackupScheduleResponses, DisableBackupScheduleErrors, EnableBackupScheduleData, EnableBackupScheduleResponses, EnableBackupScheduleErrors, GetBackupData, GetBackupResponses, GetBackupErrors, BlobDeleteData, BlobDeleteResponses, BlobDeleteErrors, BlobListData, BlobListResponses, BlobListErrors, BlobPutData, BlobPutResponses, BlobPutErrors, BlobCopyData, BlobCopyResponses, BlobCopyErrors, BlobDisableData, BlobDisableResponses, BlobDisableErrors, BlobEnableData, BlobEnableResponses, BlobEnableErrors, BlobStatusData, BlobStatusResponses, BlobStatusErrors, BlobUpdateData, BlobUpdateResponses, BlobUpdateErrors, BlobDownloadData, BlobDownloadResponses, BlobDownloadErrors, BlobHeadData, BlobHeadResponses, BlobHeadErrors, GetDashboardProjectsAnalyticsData, GetDashboardProjectsAnalyticsResponses, GetDashboardProjectsAnalyticsErrors, GetActivityGraphData, GetActivityGraphResponses, GetActivityGraphErrors, GetScanByDeploymentData, GetScanByDeploymentResponses, GetScanByDeploymentErrors, ListDnsProvidersData, ListDnsProvidersResponses, ListDnsProvidersErrors, CreateDnsProviderData, CreateDnsProviderResponses, CreateDnsProviderErrors, DeleteDnsProviderData, DeleteDnsProviderResponses, DeleteDnsProviderErrors, GetDnsProviderData, GetDnsProviderResponses, GetDnsProviderErrors, UpdateProviderData, UpdateProviderResponses, UpdateProviderErrors, ListManagedDomainsData, ListManagedDomainsResponses, ListManagedDomainsErrors, AddManagedDomainData, AddManagedDomainResponses, AddManagedDomainErrors, TestProviderConnectionData, TestProviderConnectionResponses, TestProviderConnectionErrors, ListProviderZonesData, ListProviderZonesResponses, ListProviderZonesErrors, RemoveManagedDomainData, RemoveManagedDomainResponses, RemoveManagedDomainErrors, VerifyManagedDomainData, VerifyManagedDomainResponses, VerifyManagedDomainErrors, LookupDnsARecordsData, LookupDnsARecordsResponses, LookupDnsARecordsErrors, ListDomainsData, ListDomainsResponses, ListDomainsErrors, CreateDomainData, CreateDomainResponses, CreateDomainErrors, GetDomainByHostData, GetDomainByHostResponses, GetDomainByHostErrors, CancelDomainOrderData, CancelDomainOrderResponses, CancelDomainOrderErrors, GetDomainOrderData, GetDomainOrderResponses, GetDomainOrderErrors, CreateOrRecreateOrderData, CreateOrRecreateOrderResponses, CreateOrRecreateOrderErrors, FinalizeOrderData, FinalizeOrderResponses, FinalizeOrderErrors, SetupDnsChallengeData, SetupDnsChallengeResponses, SetupDnsChallengeErrors, DeleteDomainData, DeleteDomainResponses, DeleteDomainErrors, GetDomainByIdData, GetDomainByIdResponses, GetDomainByIdErrors, GetChallengeTokenData, GetChallengeTokenResponses, GetChallengeTokenErrors, GetHttpChallengeDebugData, GetHttpChallengeDebugResponses, GetHttpChallengeDebugErrors, ProvisionDomainData, ProvisionDomainResponses, ProvisionDomainErrors, RenewDomainData, RenewDomainResponses, RenewDomainErrors, CheckDomainStatusData, CheckDomainStatusResponses, CheckDomainStatusErrors, ListEmailDomainsData, ListEmailDomainsResponses, ListEmailDomainsErrors, CreateEmailDomainData, CreateEmailDomainResponses, CreateEmailDomainErrors, GetDomainByNameData, GetDomainByNameResponses, GetDomainByNameErrors, DeleteEmailDomainData, DeleteEmailDomainResponses, DeleteEmailDomainErrors, GetDomainData, GetDomainResponses, GetDomainErrors, GetDomainDnsRecordsData, GetDomainDnsRecordsResponses, GetDomainDnsRecordsErrors, SetupDnsData, SetupDnsResponses, SetupDnsErrors, VerifyDomainData, VerifyDomainResponses, VerifyDomainErrors, ListEmailProvidersData, ListEmailProvidersResponses, ListEmailProvidersErrors, CreateEmailProviderData, CreateEmailProviderResponses, CreateEmailProviderErrors, DeleteEmailProviderData, DeleteEmailProviderResponses, DeleteEmailProviderErrors, GetEmailProviderData, GetEmailProviderResponses, GetEmailProviderErrors, TestProviderData, TestProviderResponses, TestProviderErrors, ListEmailsData, ListEmailsResponses, ListEmailsErrors, SendEmailData, SendEmailResponses, SendEmailErrors, GetGlobalEventsData, GetGlobalEventsResponses, GetGlobalEventsErrors, GetGlobalEventStatsData, GetGlobalEventStatsResponses, GetGlobalEventStatsErrors, GetEmailStatsData, GetEmailStatsResponses, GetEmailStatsErrors, ValidateEmailData, ValidateEmailResponses, ValidateEmailErrors, TrackClickData, TrackClickErrors, TrackOpenData, TrackOpenResponses, TrackOpenErrors, GetEmailData, GetEmailResponses, GetEmailErrors, GetEmailTrackingData, GetEmailTrackingResponses, GetEmailTrackingErrors, GetEmailEventsData, GetEmailEventsResponses, GetEmailEventsErrors, GetEmailLinksData, GetEmailLinksResponses, GetEmailLinksErrors, ListServicesData, ListServicesResponses, ListServicesErrors, CreateServiceData, CreateServiceResponses, CreateServiceErrors, ListAvailableContainersData, ListAvailableContainersResponses, ListAvailableContainersErrors, GetServiceBySlugData, GetServiceBySlugResponses, GetServiceBySlugErrors, ListServiceHealthStatusesData, ListServiceHealthStatusesResponses, ListServiceHealthStatusesErrors, ImportExternalServiceData, ImportExternalServiceResponses, ImportExternalServiceErrors, ListProjectServicesData, ListProjectServicesResponses, ListProjectServicesErrors, GetProjectServiceEnvironmentVariablesData, GetProjectServiceEnvironmentVariablesResponses, GetProjectServiceEnvironmentVariablesErrors, GetProvidersMetadataData, GetProvidersMetadataResponses, GetProvidersMetadataErrors, GetProviderMetadataData, GetProviderMetadataResponses, GetProviderMetadataErrors, GetServiceTypesData, GetServiceTypesResponses, GetServiceTypesErrors, GetServiceTypeParametersData, GetServiceTypeParametersResponses, GetServiceTypeParametersErrors, DeleteServiceData, DeleteServiceResponses, DeleteServiceErrors, GetServiceData, GetServiceResponses, GetServiceErrors, UpdateServiceData, UpdateServiceResponses, UpdateServiceErrors, GetClusterHealthData, GetClusterHealthResponses, GetClusterHealthErrors, TriggerServiceHealthCheckData, TriggerServiceHealthCheckResponses, TriggerServiceHealthCheckErrors, GetServiceHealthStatusData, GetServiceHealthStatusResponses, GetServiceHealthStatusErrors, AddClusterMemberData, AddClusterMemberResponses, AddClusterMemberErrors, RemoveClusterMemberData, RemoveClusterMemberResponses, RemoveClusterMemberErrors, GetClusterMemberData, GetClusterMemberResponses, GetClusterMemberErrors, PromoteClusterMemberData, PromoteClusterMemberResponses, PromoteClusterMemberErrors, GetServicePreviewEnvironmentVariablesMaskedData, GetServicePreviewEnvironmentVariablesMaskedResponses, GetServicePreviewEnvironmentVariablesMaskedErrors, GetServicePreviewEnvironmentVariableNamesData, GetServicePreviewEnvironmentVariableNamesResponses, GetServicePreviewEnvironmentVariableNamesErrors, ListServiceProjectsData, ListServiceProjectsResponses, ListServiceProjectsErrors, LinkServiceToProjectData, LinkServiceToProjectResponses, LinkServiceToProjectErrors, UnlinkServiceFromProjectData, UnlinkServiceFromProjectResponses, UnlinkServiceFromProjectErrors, GetServiceEnvironmentVariablesData, GetServiceEnvironmentVariablesResponses, GetServiceEnvironmentVariablesErrors, GetServiceEnvironmentVariableData, GetServiceEnvironmentVariableResponses, GetServiceEnvironmentVariableErrors, StartRestoreData, StartRestoreResponses, StartRestoreErrors, GetRestoreCapabilitiesData, GetRestoreCapabilitiesResponses, GetRestoreCapabilitiesErrors, PlanRestoreData, PlanRestoreResponses, PlanRestoreErrors, ListRestoreRunsForServiceData, ListRestoreRunsForServiceResponses, RetryClusterData, RetryClusterResponses, RetryClusterErrors, StartServiceData, StartServiceResponses, StartServiceErrors, StopServiceData, StopServiceResponses, StopServiceErrors, UpgradeServiceData, UpgradeServiceResponses, UpgradeServiceErrors, ListRootContainersData, ListRootContainersResponses, ListRootContainersErrors, ListContainersAtPathData, ListContainersAtPathResponses, ListContainersAtPathErrors, ListEntitiesData, ListEntitiesResponses, ListEntitiesErrors, GetEntityInfoData, GetEntityInfoResponses, GetEntityInfoErrors, QueryDataData, QueryDataResponses, QueryDataErrors, DownloadObjectData, DownloadObjectResponses, DownloadObjectErrors, GetContainerInfoData, GetContainerInfoResponses, GetContainerInfoErrors, CheckExplorerSupportData, CheckExplorerSupportResponses, CheckExplorerSupportErrors, ListPgUpgradesData, ListPgUpgradesResponses, ListPgUpgradesErrors, StartPgUpgradeData, StartPgUpgradeResponses, StartPgUpgradeErrors, GetPgUpgradeData, GetPgUpgradeResponses, GetPgUpgradeErrors, CancelPgUpgradeData, CancelPgUpgradeResponses, CancelPgUpgradeErrors, GetPgUpgradeLogsData, GetPgUpgradeLogsResponses, GetPgUpgradeLogsErrors, RetryPgUpgradeData, RetryPgUpgradeResponses, RetryPgUpgradeErrors, RollbackPgUpgradeData, RollbackPgUpgradeResponses, RollbackPgUpgradeErrors, GetFileData, GetFileResponses, GetFileErrors, GetIpGeolocationData, GetIpGeolocationResponses, GetIpGeolocationErrors, ListConnectionsData, ListConnectionsResponses, ListConnectionsErrors, DeleteConnectionData, DeleteConnectionResponses, DeleteConnectionErrors, ActivateConnectionData, ActivateConnectionResponses, ActivateConnectionErrors, DeactivateConnectionData, DeactivateConnectionResponses, DeactivateConnectionErrors, RunConnectionHealthCheckData, RunConnectionHealthCheckResponses, RunConnectionHealthCheckErrors, ListRepositoriesByConnectionData, ListRepositoriesByConnectionResponses, ListRepositoriesByConnectionErrors, SyncRepositoriesData, SyncRepositoriesResponses, SyncRepositoriesErrors, UpdateConnectionTokenData, UpdateConnectionTokenResponses, UpdateConnectionTokenErrors, ValidateConnectionData, ValidateConnectionResponses, ValidateConnectionErrors, ListGitProvidersData, ListGitProvidersResponses, ListGitProvidersErrors, CreateGitProviderData, CreateGitProviderResponses, CreateGitProviderErrors, CreateGithubPatProviderData, CreateGithubPatProviderResponses, CreateGithubPatProviderErrors, CreateGitlabOauthProviderData, CreateGitlabOauthProviderResponses, CreateGitlabOauthProviderErrors, CreateGitlabPatProviderData, CreateGitlabPatProviderResponses, CreateGitlabPatProviderErrors, DeleteGitProviderData, DeleteGitProviderResponses, DeleteGitProviderErrors, GetGitProviderData, GetGitProviderResponses, GetGitProviderErrors, ActivateProviderData, ActivateProviderResponses, ActivateProviderErrors, HandleGitProviderOauthCallbackData, HandleGitProviderOauthCallbackErrors, GetProviderConnectionsData, GetProviderConnectionsResponses, GetProviderConnectionsErrors, UpdateGitProviderCredentialsData, UpdateGitProviderCredentialsResponses, UpdateGitProviderCredentialsErrors, DeactivateProviderData, DeactivateProviderResponses, DeactivateProviderErrors, CheckProviderDeletionSafetyData, CheckProviderDeletionSafetyResponses, CheckProviderDeletionSafetyErrors, StartGitProviderOauthData, StartGitProviderOauthErrors, DeleteProviderSafelyData, DeleteProviderSafelyResponses, DeleteProviderSafelyErrors, GetPublicRepositoryData, GetPublicRepositoryResponses, GetPublicRepositoryErrors, GetPublicBranchesData, GetPublicBranchesResponses, GetPublicBranchesErrors, DetectPublicPresetsData, DetectPublicPresetsResponses, DetectPublicPresetsErrors, DiscoverWorkloadsData, DiscoverWorkloadsResponses, DiscoverWorkloadsErrors, ExecuteImportData, ExecuteImportResponses, ExecuteImportErrors, CreatePlanData, CreatePlanResponses, CreatePlanErrors, ListSourcesData, ListSourcesResponses, ListSourcesErrors, GetImportStatusData, GetImportStatusResponses, GetImportStatusErrors, GetIncidentData, GetIncidentResponses, GetIncidentErrors, UpdateIncidentStatusData, UpdateIncidentStatusResponses, UpdateIncidentStatusErrors, GetIncidentUpdatesData, GetIncidentUpdatesResponses, GetIncidentUpdatesErrors, AdminListNodesData, AdminListNodesResponses, AdminListNodesErrors, RegisterNodeData, RegisterNodeResponses, RegisterNodeErrors, AdminRemoveNodeData, AdminRemoveNodeResponses, AdminRemoveNodeErrors, AdminGetNodeData, AdminGetNodeResponses, AdminGetNodeErrors, AdminListNodeContainersData, AdminListNodeContainersResponses, AdminListNodeContainersErrors, PostDnsAckData, PostDnsAckResponses, PostDnsAckErrors, GetDnsChangesData, GetDnsChangesResponses, GetDnsChangesErrors, AdminUndrainNodeData, AdminUndrainNodeResponses, AdminUndrainNodeErrors, AdminDrainStatusData, AdminDrainStatusResponses, AdminDrainStatusErrors, AdminDrainNodeData, AdminDrainNodeResponses, AdminDrainNodeErrors, NodeHeartbeatData, NodeHeartbeatResponses, NodeHeartbeatErrors, ListPeersData, ListPeersResponses, ListPeersErrors, GetS3CredentialsData, GetS3CredentialsResponses, GetS3CredentialsErrors, ListIpAccessControlData, ListIpAccessControlResponses, ListIpAccessControlErrors, CreateIpAccessControlData, CreateIpAccessControlResponses, CreateIpAccessControlErrors, CheckIpBlockedData, CheckIpBlockedResponses, CheckIpBlockedErrors, DeleteIpAccessControlData, DeleteIpAccessControlResponses, DeleteIpAccessControlErrors, GetIpAccessControlData, GetIpAccessControlResponses, GetIpAccessControlErrors, UpdateIpAccessControlData, UpdateIpAccessControlResponses, UpdateIpAccessControlErrors, KvDelData, KvDelResponses, KvDelErrors, KvDisableData, KvDisableResponses, KvDisableErrors, KvEnableData, KvEnableResponses, KvEnableErrors, KvExpireData, KvExpireResponses, KvExpireErrors, KvGetData, KvGetResponses, KvGetErrors, KvIncrData, KvIncrResponses, KvIncrErrors, KvKeysData, KvKeysResponses, KvKeysErrors, KvSetData, KvSetResponses, KvSetErrors, KvStatusData, KvStatusResponses, KvStatusErrors, KvTtlData, KvTtlResponses, KvTtlErrors, KvUpdateData, KvUpdateResponses, KvUpdateErrors, ListRoutesData, ListRoutesResponses, ListRoutesErrors, CreateRouteData, CreateRouteResponses, CreateRouteErrors, DeleteRouteData, DeleteRouteResponses, DeleteRouteErrors, GetRouteData, GetRouteResponses, GetRouteErrors, UpdateRouteData, UpdateRouteResponses, UpdateRouteErrors, LogoutData, LogoutResponses, LogoutErrors, GetLogContextData, GetLogContextResponses, GetLogContextErrors, SearchLogsData, SearchLogsResponses, SearchLogsErrors, TailLogsData, TailLogsResponses, TailLogsErrors, GetProjectsMonitorHealthData, GetProjectsMonitorHealthResponses, GetProjectsMonitorHealthErrors, DeleteMonitorData, DeleteMonitorResponses, DeleteMonitorErrors, GetMonitorData, GetMonitorResponses, GetMonitorErrors, GetBucketedStatusData, GetBucketedStatusResponses, GetBucketedStatusErrors, GetCurrentMonitorStatusData, GetCurrentMonitorStatusResponses, GetCurrentMonitorStatusErrors, GetUptimeHistoryData, GetUptimeHistoryResponses, GetUptimeHistoryErrors, DeletePreferencesData, DeletePreferencesResponses, DeletePreferencesErrors, GetPreferencesData, GetPreferencesResponses, GetPreferencesErrors, UpdatePreferencesData, UpdatePreferencesResponses, UpdatePreferencesErrors, ListNotificationProvidersData, ListNotificationProvidersResponses, ListNotificationProvidersErrors, CreateNotificationProviderData, CreateNotificationProviderResponses, CreateNotificationProviderErrors, CreateNotificationEmailProviderData, CreateNotificationEmailProviderResponses, CreateNotificationEmailProviderErrors, UpdateEmailProviderData, UpdateEmailProviderResponses, UpdateEmailProviderErrors, CreateSlackProviderData, CreateSlackProviderResponses, CreateSlackProviderErrors, UpdateSlackProviderData, UpdateSlackProviderResponses, UpdateSlackProviderErrors, CreateWebhookProviderData, CreateWebhookProviderResponses, CreateWebhookProviderErrors, UpdateWebhookProviderData, UpdateWebhookProviderResponses, UpdateWebhookProviderErrors, DeleteNotificationProviderData, DeleteNotificationProviderResponses, DeleteNotificationProviderErrors, GetNotificationProviderData, GetNotificationProviderResponses, GetNotificationProviderErrors, UpdateNotificationProviderData, UpdateNotificationProviderResponses, UpdateNotificationProviderErrors, TestNotificationProviderData, TestNotificationProviderResponses, TestNotificationProviderErrors, ListOrdersData, ListOrdersResponses, ListOrdersErrors, QueryGenaiTracesData, QueryGenaiTracesResponses, QueryGenaiTracesErrors, GetGenaiTraceData, GetGenaiTraceResponses, GetGenaiTraceErrors, GetHealthData, GetHealthResponses, GetHealthErrors, ListInsightsData, ListInsightsResponses, ListInsightsErrors, QueryLogsData, QueryLogsResponses, QueryLogsErrors, ListMetricNamesData, ListMetricNamesResponses, ListMetricNamesErrors, QueryMetricsData, QueryMetricsResponses, QueryMetricsErrors, GetPipelineStatsData, GetPipelineStatsResponses, GetPipelineStatsErrors, GetQuotaData, GetQuotaResponses, GetQuotaErrors, QueryTraceSummariesData, QueryTraceSummariesResponses, QueryTraceSummariesErrors, QueryTracesData, QueryTracesResponses, QueryTracesErrors, GetTraceData, GetTraceResponses, GetTraceErrors, IngestLogsData, IngestLogsResponses, IngestLogsErrors, IngestMetricsData, IngestMetricsResponses, IngestMetricsErrors, IngestTracesData, IngestTracesResponses, IngestTracesErrors, IngestLogsByPathData, IngestLogsByPathResponses, IngestLogsByPathErrors, IngestMetricsByPathData, IngestMetricsByPathResponses, IngestMetricsByPathErrors, IngestTracesByPathData, IngestTracesByPathResponses, IngestTracesByPathErrors, HasPerformanceMetricsData, HasPerformanceMetricsResponses, HasPerformanceMetricsErrors, GetPerformanceMetricsData, GetPerformanceMetricsResponses, GetPerformanceMetricsErrors, GetMetricsOverTimeData, GetMetricsOverTimeResponses, GetMetricsOverTimeErrors, GetGroupedPageMetricsData, GetGroupedPageMetricsResponses, GetGroupedPageMetricsErrors, GetAccessInfoData, GetAccessInfoResponses, GetAccessInfoErrors, GetPrivateIpData, GetPrivateIpResponses, GetPrivateIpErrors, GetPublicIpData, GetPublicIpResponses, GetPublicIpErrors, ListPresetsData, ListPresetsResponses, ListPresetsErrors, GeneratePresetDockerfileData, GeneratePresetDockerfileResponses, GeneratePresetDockerfileErrors, GetPreviewGatewayLogsData, GetPreviewGatewayLogsResponses, RestartPreviewGatewayData, RestartPreviewGatewayResponses, GetPreviewGatewaySettingsData, GetPreviewGatewaySettingsResponses, PatchPreviewGatewaySettingsData, PatchPreviewGatewaySettingsResponses, GetPreviewGatewayStatusData, GetPreviewGatewayStatusResponses, UpgradePreviewGatewayData, UpgradePreviewGatewayResponses, GetProjectsData, GetProjectsResponses, GetProjectsErrors, CreateProjectData, CreateProjectResponses, CreateProjectErrors, GetProjectBySlugData, GetProjectBySlugResponses, GetProjectBySlugErrors, CreateProjectFromTemplateData, CreateProjectFromTemplateResponses, CreateProjectFromTemplateErrors, GetProjectStatisticsData, GetProjectStatisticsResponses, GetProjectStatisticsErrors, DeleteProjectData, DeleteProjectResponses, DeleteProjectErrors, GetProjectData, GetProjectResponses, GetProjectErrors, UpdateProjectData, UpdateProjectResponses, UpdateProjectErrors, GetProjectDeploymentsData, GetProjectDeploymentsResponses, GetProjectDeploymentsErrors, GetLastDeploymentData, GetLastDeploymentResponses, GetLastDeploymentErrors, TriggerProjectPipelineData, TriggerProjectPipelineResponses, TriggerProjectPipelineErrors, GetActiveVisitorsData, GetActiveVisitorsResponses, GetActiveVisitorsErrors, ListAgentsData, ListAgentsResponses, ListAgentsErrors, CreateAgentData, CreateAgentResponses, CreateAgentErrors, GetCliStatusData, GetCliStatusResponses, GetCliStatusErrors, ListAllRunsData, ListAllRunsResponses, ListAllRunsErrors, LatestRunForSourceData, LatestRunForSourceResponses, LatestRunForSourceErrors, GetRunWithLogsData, GetRunWithLogsResponses, GetRunWithLogsErrors, CancelRunData, CancelRunResponses, CancelRunErrors, RetryRunData, RetryRunResponses, RetryRunErrors, StreamRunEventsData, StreamRunEventsResponses, StreamRunEventsErrors, GetSandboxStatusData, GetSandboxStatusResponses, GetSandboxStatusErrors, SmokeTestAgentData, SmokeTestAgentResponses, SmokeTestAgentErrors, DeleteAgentData, DeleteAgentResponses, DeleteAgentErrors, GetAgentData, GetAgentResponses, GetAgentErrors, UpdateAgentData, UpdateAgentResponses, UpdateAgentErrors, ListAgentRunsData, ListAgentRunsResponses, ListAgentRunsErrors, TriggerAgentData, TriggerAgentResponses, TriggerAgentErrors, GetAggregatedBucketsData, GetAggregatedBucketsResponses, GetAggregatedBucketsErrors, StartAnalysisData, StartAnalysisResponses, StartAnalysisErrors, GetRunData, GetRunResponses, GetRunErrors, AddContextData, AddContextResponses, AddContextErrors, CancelData, CancelResponses, CancelErrors, CreatePrData, CreatePrResponses, CreatePrErrors, StartFixData, StartFixResponses, StartFixErrors, ReAnalyzeData, ReAnalyzeResponses, ReAnalyzeErrors, StreamEventsData, StreamEventsResponses, StreamEventsErrors, UpdateAutomaticDeployData, UpdateAutomaticDeployResponses, UpdateAutomaticDeployErrors, ListCustomDomainsForProjectData, ListCustomDomainsForProjectResponses, ListCustomDomainsForProjectErrors, CreateCustomDomainData, CreateCustomDomainResponses, CreateCustomDomainErrors, DeleteCustomDomainData, DeleteCustomDomainResponses, DeleteCustomDomainErrors, GetCustomDomainData, GetCustomDomainResponses, GetCustomDomainErrors, UpdateCustomDomainData, UpdateCustomDomainResponses, UpdateCustomDomainErrors, LinkCustomDomainToCertificateData, LinkCustomDomainToCertificateResponses, LinkCustomDomainToCertificateErrors, UpdateProjectDeploymentConfigData, UpdateProjectDeploymentConfigResponses, UpdateProjectDeploymentConfigErrors, GetDeploymentData, GetDeploymentResponses, GetDeploymentErrors, CancelDeploymentData, CancelDeploymentResponses, CancelDeploymentErrors, GetDeploymentJobsData, GetDeploymentJobsResponses, GetDeploymentJobsErrors, GetDeploymentJobLogsData, GetDeploymentJobLogsResponses, GetDeploymentJobLogsErrors, TailDeploymentJobLogsData, TailDeploymentJobLogsErrors, GetDeploymentOperationsData, GetDeploymentOperationsResponses, GetDeploymentOperationsErrors, ExecuteDeploymentOperationData, ExecuteDeploymentOperationResponses, ExecuteDeploymentOperationErrors, GetDeploymentOperationStatusData, GetDeploymentOperationStatusResponses, GetDeploymentOperationStatusErrors, PauseDeploymentData, PauseDeploymentResponses, PauseDeploymentErrors, PromoteDeploymentData, PromoteDeploymentResponses, PromoteDeploymentErrors, ResumeDeploymentData, ResumeDeploymentResponses, ResumeDeploymentErrors, RollbackToDeploymentData, RollbackToDeploymentResponses, RollbackToDeploymentErrors, TeardownDeploymentData, TeardownDeploymentResponses, TeardownDeploymentErrors, ListDsnsData, ListDsnsResponses, CreateDsnData, CreateDsnResponses, CreateDsnErrors, GetOrCreateDsnData, GetOrCreateDsnResponses, GetOrCreateDsnErrors, RegenerateDsnData, RegenerateDsnResponses, RegenerateDsnErrors, RevokeDsnData, RevokeDsnResponses, RevokeDsnErrors, GetEnvironmentVariablesData, GetEnvironmentVariablesResponses, GetEnvironmentVariablesErrors, CreateEnvironmentVariableData, CreateEnvironmentVariableResponses, CreateEnvironmentVariableErrors, GetResolvedEnvironmentVariablesData, GetResolvedEnvironmentVariablesResponses, GetResolvedEnvironmentVariablesErrors, GetResolvedEnvironmentVariableValueData, GetResolvedEnvironmentVariableValueResponses, GetResolvedEnvironmentVariableValueErrors, GetEnvironmentVariableValueData, GetEnvironmentVariableValueResponses, GetEnvironmentVariableValueErrors, DeleteEnvironmentVariableData, DeleteEnvironmentVariableResponses, DeleteEnvironmentVariableErrors, UpdateEnvironmentVariableData, UpdateEnvironmentVariableResponses, UpdateEnvironmentVariableErrors, GetEnvironmentsData, GetEnvironmentsResponses, GetEnvironmentsErrors, CreateEnvironmentData, CreateEnvironmentResponses, CreateEnvironmentErrors, DeleteEnvironmentData, DeleteEnvironmentResponses, DeleteEnvironmentErrors, GetEnvironmentData, GetEnvironmentResponses, GetEnvironmentErrors, GetEnvironmentCronsData, GetEnvironmentCronsResponses, GetEnvironmentCronsErrors, GetCronByIdData, GetCronByIdResponses, GetCronByIdErrors, GetCronExecutionsData, GetCronExecutionsResponses, GetCronExecutionsErrors, GetEnvironmentDomainsData, GetEnvironmentDomainsResponses, GetEnvironmentDomainsErrors, AddEnvironmentDomainData, AddEnvironmentDomainResponses, AddEnvironmentDomainErrors, DeleteEnvironmentDomainData, DeleteEnvironmentDomainResponses, DeleteEnvironmentDomainErrors, UpdateEnvironmentSettingsData, UpdateEnvironmentSettingsResponses, UpdateEnvironmentSettingsErrors, SleepEnvironmentData, SleepEnvironmentResponses, SleepEnvironmentErrors, TeardownEnvironmentData, TeardownEnvironmentResponses, TeardownEnvironmentErrors, WakeEnvironmentData, WakeEnvironmentResponses, WakeEnvironmentErrors, GetContainerLogsData, GetContainerLogsErrors, ListContainersData, ListContainersResponses, ListContainersErrors, GetContainerDetailData, GetContainerDetailResponses, GetContainerDetailErrors, GetContainerLogsByIdData, GetContainerLogsByIdErrors, GetContainerMetricsData, GetContainerMetricsResponses, GetContainerMetricsErrors, StreamContainerMetricsData, StreamContainerMetricsResponses, StreamContainerMetricsErrors, RestartContainerData, RestartContainerResponses, RestartContainerErrors, StartContainerData, StartContainerResponses, StartContainerErrors, StopContainerData, StopContainerResponses, StopContainerErrors, DeployFromImageData, DeployFromImageResponses, DeployFromImageErrors, DeployFromImageUploadData, DeployFromImageUploadResponses, DeployFromImageUploadErrors, DeployFromStaticData, DeployFromStaticResponses, DeployFromStaticErrors, ListAlertRulesData, ListAlertRulesResponses, ListAlertRulesErrors, CreateAlertRuleData, CreateAlertRuleResponses, CreateAlertRuleErrors, DeleteAlertRuleData, DeleteAlertRuleResponses, DeleteAlertRuleErrors, GetAlertRuleData, GetAlertRuleResponses, GetAlertRuleErrors, UpdateAlertRuleData, UpdateAlertRuleResponses, UpdateAlertRuleErrors, GetErrorDashboardStatsData, GetErrorDashboardStatsResponses, GetErrorDashboardStatsErrors, ListErrorGroupsData, ListErrorGroupsResponses, ListErrorGroupsErrors, GetErrorGroupData, GetErrorGroupResponses, GetErrorGroupErrors, UpdateErrorGroupData, UpdateErrorGroupResponses, UpdateErrorGroupErrors, ListErrorEventsData, ListErrorEventsResponses, ListErrorEventsErrors, GetErrorEventData, GetErrorEventResponses, GetErrorEventErrors, GetErrorStatsData, GetErrorStatsResponses, GetErrorStatsErrors, GetErrorTimeSeriesData, GetErrorTimeSeriesResponses, GetErrorTimeSeriesErrors, GetEventsCountData, GetEventsCountResponses, GetEventsCountErrors, GetEventTypeBreakdownData, GetEventTypeBreakdownResponses, GetEventTypeBreakdownErrors, RecordConsoleEventData, RecordConsoleEventResponses, RecordConsoleEventErrors, GetPropertyBreakdownData, GetPropertyBreakdownResponses, GetPropertyBreakdownErrors, GetPropertyTimelineData, GetPropertyTimelineResponses, GetPropertyTimelineErrors, GetEventsTimelineData, GetEventsTimelineResponses, GetEventsTimelineErrors, GetUniqueEventsData, GetUniqueEventsResponses, GetUniqueEventsErrors, ListRemoteExternalImagesData, ListRemoteExternalImagesResponses, ListRemoteExternalImagesErrors, RegisterExternalImageData, RegisterExternalImageResponses, RegisterExternalImageErrors, DeleteExternalImageData, DeleteExternalImageResponses, DeleteExternalImageErrors, GetRemoteExternalImageData, GetRemoteExternalImageResponses, GetRemoteExternalImageErrors, ListFunnelsData, ListFunnelsResponses, ListFunnelsErrors, CreateFunnelData, CreateFunnelResponses, CreateFunnelErrors, PreviewFunnelMetricsData, PreviewFunnelMetricsResponses, PreviewFunnelMetricsErrors, DeleteFunnelData, DeleteFunnelResponses, DeleteFunnelErrors, UpdateFunnelData, UpdateFunnelResponses, UpdateFunnelErrors, GetFunnelMetricsData, GetFunnelMetricsResponses, GetFunnelMetricsErrors, UpdateGitSettingsData, UpdateGitSettingsResponses, UpdateGitSettingsErrors, HasErrorGroupsData, HasErrorGroupsResponses, HasErrorGroupsErrors, HasAnalyticsEventsData, HasAnalyticsEventsResponses, HasAnalyticsEventsErrors, GetHourlyVisitsData, GetHourlyVisitsResponses, GetHourlyVisitsErrors, ListExternalImagesData, ListExternalImagesResponses, ListExternalImagesErrors, PushExternalImageData, PushExternalImageResponses, PushExternalImageErrors, GetExternalImageData, GetExternalImageResponses, GetExternalImageErrors, ListIncidentsData, ListIncidentsResponses, ListIncidentsErrors, CreateIncidentData, CreateIncidentResponses, CreateIncidentErrors, GetBucketedIncidentsData, GetBucketedIncidentsResponses, GetBucketedIncidentsErrors, PurgeProjectLogsData, PurgeProjectLogsResponses, PurgeProjectLogsErrors, ListMcpsData, ListMcpsResponses, ListMcpsErrors, CreateMcpData, CreateMcpResponses, CreateMcpErrors, DeleteMcpData, DeleteMcpResponses, DeleteMcpErrors, GetMcpData, GetMcpResponses, GetMcpErrors, UpdateMcpData, UpdateMcpResponses, UpdateMcpErrors, ListMonitorsData, ListMonitorsResponses, ListMonitorsErrors, CreateMonitorData, CreateMonitorResponses, CreateMonitorErrors, DeleteReleaseSourceMapsData, DeleteReleaseSourceMapsResponses, DeleteReleaseSourceMapsErrors, ListSourceMapsData, ListSourceMapsResponses, ListSourceMapsErrors, UploadSourceMapData, UploadSourceMapResponses, UploadSourceMapErrors, RevenueRecentEventsData, RevenueRecentEventsResponses, RevenueListIntegrationsData, RevenueListIntegrationsResponses, RevenueCreateIntegrationData, RevenueCreateIntegrationResponses, RevenueCreateIntegrationErrors, RevenueDeleteIntegrationData, RevenueDeleteIntegrationResponses, RevenueUpdateConfigData, RevenueUpdateConfigResponses, RevenueUpdateConfigErrors, RevenueImportInvoicesCsvData, RevenueImportInvoicesCsvResponses, RevenueImportInvoicesCsvErrors, RevenueImportSubscriptionsCsvData, RevenueImportSubscriptionsCsvResponses, RevenueImportSubscriptionsCsvErrors, RevenueRotateTokenData, RevenueRotateTokenResponses, RevenueUpdateSecretData, RevenueUpdateSecretResponses, RevenueUpdateSecretErrors, RevenueMetricsCustomersData, RevenueMetricsCustomersResponses, RevenueMetricsMrrData, RevenueMetricsMrrResponses, RevenueMetricsSummaryData, RevenueMetricsSummaryResponses, ListProjectSecretsData, ListProjectSecretsResponses, ListProjectSecretsErrors, CreateProjectSecretData, CreateProjectSecretResponses, CreateProjectSecretErrors, DeleteProjectSecretData, DeleteProjectSecretResponses, DeleteProjectSecretErrors, UpdateProjectSecretData, UpdateProjectSecretResponses, UpdateProjectSecretErrors, UpdateProjectSettingsData, UpdateProjectSettingsResponses, UpdateProjectSettingsErrors, ListSkillsData, ListSkillsResponses, ListSkillsErrors, CreateSkillData, CreateSkillResponses, CreateSkillErrors, UploadSkillData, UploadSkillResponses, UploadSkillErrors, DeleteSkillData, DeleteSkillResponses, DeleteSkillErrors, GetSkillData, GetSkillResponses, GetSkillErrors, UpdateSkillData, UpdateSkillResponses, UpdateSkillErrors, DownloadSkillArchiveData, DownloadSkillArchiveResponses, DownloadSkillArchiveErrors, ListReleasesData, ListReleasesResponses, ListReleasesErrors, DeleteSourceMapData, DeleteSourceMapResponses, DeleteSourceMapErrors, ListStaticBundlesData, ListStaticBundlesResponses, ListStaticBundlesErrors, DeleteStaticBundleData, DeleteStaticBundleResponses, DeleteStaticBundleErrors, GetStaticBundleData, GetStaticBundleResponses, GetStaticBundleErrors, GetStatusOverviewData, GetStatusOverviewResponses, GetStatusOverviewErrors, GetUniqueCountsData, GetUniqueCountsResponses, GetUniqueCountsErrors, UploadStaticBundleData, UploadStaticBundleResponses, UploadStaticBundleErrors, ListProjectScansData, ListProjectScansResponses, ListProjectScansErrors, TriggerScanData, TriggerScanResponses, TriggerScanErrors, GetLatestScansPerEnvironmentData, GetLatestScansPerEnvironmentResponses, GetLatestScansPerEnvironmentErrors, GetLatestScanData, GetLatestScanResponses, GetLatestScanErrors, ListWebhooksData, ListWebhooksResponses, ListWebhooksErrors, CreateWebhookData, CreateWebhookResponses, CreateWebhookErrors, DeleteWebhookData, DeleteWebhookResponses, DeleteWebhookErrors, GetWebhookData, GetWebhookResponses, GetWebhookErrors, UpdateWebhookData, UpdateWebhookResponses, UpdateWebhookErrors, ListDeliveriesData, ListDeliveriesResponses, ListDeliveriesErrors, GetDeliveryData, GetDeliveryResponses, GetDeliveryErrors, RetryDeliveryData, RetryDeliveryResponses, RetryDeliveryErrors, WorkflowDryRunData, WorkflowDryRunResponses, WorkflowDryRunErrors, ListMemoryData, ListMemoryResponses, ListMemoryErrors, WriteMemoryData, WriteMemoryResponses, WriteMemoryErrors, SearchMemoryData, SearchMemoryResponses, SearchMemoryErrors, DropMemoryData, DropMemoryResponses, DropMemoryErrors, SupersedeMemoryData, SupersedeMemoryResponses, SupersedeMemoryErrors, WorkspaceListSessionsData, WorkspaceListSessionsResponses, WorkspaceListSessionsErrors, WorkspaceStartSessionData, WorkspaceStartSessionResponses, WorkspaceStartSessionErrors, WorkspaceDeleteSessionData, WorkspaceDeleteSessionResponses, WorkspaceDeleteSessionErrors, WorkspaceGetSessionData, WorkspaceGetSessionResponses, WorkspaceGetSessionErrors, WorkspaceUpdateSessionData, WorkspaceUpdateSessionResponses, WorkspaceUpdateSessionErrors, WorkspaceCancelRunData, WorkspaceCancelRunResponses, WorkspaceCancelRunErrors, WorkspaceCloseSessionData, WorkspaceCloseSessionResponses, WorkspaceCloseSessionErrors, WorkspaceSendMessageData, WorkspaceSendMessageResponses, WorkspaceSendMessageErrors, WorkspaceRegeneratePreviewPasswordData, WorkspaceRegeneratePreviewPasswordResponses, WorkspaceRegeneratePreviewPasswordErrors, WorkspaceReopenSessionData, WorkspaceReopenSessionResponses, WorkspaceReopenSessionErrors, WorkspaceRefreshSandboxData, WorkspaceRefreshSandboxResponses, WorkspaceRefreshSandboxErrors, WorkspaceRestartSandboxData, WorkspaceRestartSandboxResponses, WorkspaceRestartSandboxErrors, WorkspaceStartSandboxData, WorkspaceStartSandboxResponses, WorkspaceStartSandboxErrors, WorkspaceSandboxStatsData, WorkspaceSandboxStatsResponses, WorkspaceSandboxStatsErrors, WorkspaceStopSandboxData, WorkspaceStopSandboxResponses, WorkspaceStopSandboxErrors, WorkspaceStreamMessagesData, WorkspaceStreamMessagesResponses, WorkspaceStreamMessagesErrors, WorkspaceTerminalPasteImageData, WorkspaceTerminalPasteImageResponses, WorkspaceTerminalPasteImageErrors, WorkspaceListTerminalTabsData, WorkspaceListTerminalTabsResponses, WorkspaceListTerminalTabsErrors, WorkspaceDeleteTerminalTabData, WorkspaceDeleteTerminalTabResponses, WorkspaceDeleteTerminalTabErrors, GetProxyLogsData, GetProxyLogsResponses, GetProxyLogsErrors, GetProxyLogByRequestIdData, GetProxyLogByRequestIdResponses, GetProxyLogByRequestIdErrors, GetProjectsHealthData, GetProjectsHealthResponses, GetProjectsHealthErrors, GetTimeBucketStatsData, GetTimeBucketStatsResponses, GetTimeBucketStatsErrors, GetTodayStatsData, GetTodayStatsResponses, GetTodayStatsErrors, GetProxyLogByIdData, GetProxyLogByIdResponses, GetProxyLogByIdErrors, ListSyncedRepositoriesData, ListSyncedRepositoriesResponses, ListSyncedRepositoriesErrors, GetRepositoryByNameData, GetRepositoryByNameResponses, GetRepositoryByNameErrors, GetAllRepositoriesByNameData, GetAllRepositoriesByNameResponses, GetAllRepositoriesByNameErrors, GetRepositoryPresetByNameData, GetRepositoryPresetByNameResponses, GetRepositoryPresetByNameErrors, GetRepositoryBranchesData, GetRepositoryBranchesResponses, GetRepositoryBranchesErrors, GetRepositoryTagsData, GetRepositoryTagsResponses, GetRepositoryTagsErrors, GetRepositoryPresetLiveData, GetRepositoryPresetLiveResponses, GetRepositoryPresetLiveErrors, GetRepositoryByIdData, GetRepositoryByIdResponses, GetRepositoryByIdErrors, GetBranchesByRepositoryIdData, GetBranchesByRepositoryIdResponses, GetBranchesByRepositoryIdErrors, ListCommitsByRepositoryIdData, ListCommitsByRepositoryIdResponses, ListCommitsByRepositoryIdErrors, CheckCommitExistsData, CheckCommitExistsResponses, CheckCommitExistsErrors, GetTagsByRepositoryIdData, GetTagsByRepositoryIdResponses, GetTagsByRepositoryIdErrors, GetRestoreRunData, GetRestoreRunResponses, GetRestoreRunErrors, RevenueGlobalEventsData, RevenueGlobalEventsResponses, RevenueMetricsGlobalMrrData, RevenueMetricsGlobalMrrResponses, RevenueMetricsGlobalSummaryData, RevenueMetricsGlobalSummaryResponses, RevenueListProvidersData, RevenueListProvidersResponses, GetProjectSessionReplaysData, GetProjectSessionReplaysResponses, GetProjectSessionReplaysErrors, GetSessionEventsData, GetSessionEventsResponses, GetSessionEventsErrors, GetSettingsData, GetSettingsResponses, GetSettingsErrors, UpdateSettingsData, UpdateSettingsResponses, UpdateSettingsErrors, SaveAgentTokenData, SaveAgentTokenResponses, SaveAgentTokenErrors, ListAiProvidersData, ListAiProvidersResponses, ListAiProvidersErrors, UpdateAiProviderData, UpdateAiProviderResponses, UpdateAiProviderErrors, ActivateAiProviderData, ActivateAiProviderResponses, ActivateAiProviderErrors, SaveAiProviderCredentialData, SaveAiProviderCredentialResponses, SaveAiProviderCredentialErrors, RevokeJoinTokenData, RevokeJoinTokenResponses, RevokeJoinTokenErrors, GenerateJoinTokenData, GenerateJoinTokenResponses, GenerateJoinTokenErrors, GetJoinTokenStatusData, GetJoinTokenStatusResponses, GetJoinTokenStatusErrors, ListGlobalMcpsData, ListGlobalMcpsResponses, ListGlobalMcpsErrors, CreateGlobalMcpData, CreateGlobalMcpResponses, CreateGlobalMcpErrors, DeleteGlobalMcpData, DeleteGlobalMcpResponses, DeleteGlobalMcpErrors, GetGlobalMcpData, GetGlobalMcpResponses, GetGlobalMcpErrors, UpdateGlobalMcpData, UpdateGlobalMcpResponses, UpdateGlobalMcpErrors, RefreshRouteTableData, RefreshRouteTableResponses, RefreshRouteTableErrors, RebuildSandboxImageData, RebuildSandboxImageResponses, RebuildSandboxImageErrors, GetGlobalSandboxStatusData, GetGlobalSandboxStatusResponses, GetGlobalSandboxStatusErrors, ListSecretsData, ListSecretsResponses, ListSecretsErrors, UpsertSecretData, UpsertSecretResponses, UpsertSecretErrors, DeleteSecretData, DeleteSecretResponses, DeleteSecretErrors, ListGlobalSkillsData, ListGlobalSkillsResponses, ListGlobalSkillsErrors, CreateGlobalSkillData, CreateGlobalSkillResponses, CreateGlobalSkillErrors, UploadGlobalSkillData, UploadGlobalSkillResponses, UploadGlobalSkillErrors, DeleteGlobalSkillData, DeleteGlobalSkillResponses, DeleteGlobalSkillErrors, GetGlobalSkillData, GetGlobalSkillResponses, GetGlobalSkillErrors, UpdateGlobalSkillData, UpdateGlobalSkillResponses, UpdateGlobalSkillErrors, DownloadGlobalSkillArchiveData, DownloadGlobalSkillArchiveResponses, DownloadGlobalSkillArchiveErrors, ListProjectTemplatesData, ListProjectTemplatesResponses, ListProjectTemplatesErrors, ListProjectTemplateTagsData, ListProjectTemplateTagsResponses, ListProjectTemplateTagsErrors, GetProjectTemplateData, GetProjectTemplateResponses, GetProjectTemplateErrors, GetCurrentUserData, GetCurrentUserResponses, GetCurrentUserErrors, ListUsersData, ListUsersResponses, ListUsersErrors, CreateUserData, CreateUserResponses, CreateUserErrors, UpdateSelfData, UpdateSelfResponses, UpdateSelfErrors, DisableMfaData, DisableMfaResponses, DisableMfaErrors, SetupMfaData, SetupMfaResponses, SetupMfaErrors, VerifyAndEnableMfaData, VerifyAndEnableMfaResponses, VerifyAndEnableMfaErrors, DeleteUserData, DeleteUserResponses, DeleteUserErrors, UpdateUserData, UpdateUserResponses, UpdateUserErrors, RestoreUserData, RestoreUserResponses, RestoreUserErrors, AssignRoleData, AssignRoleResponses, AssignRoleErrors, RemoveRoleData, RemoveRoleResponses, RemoveRoleErrors, ListSandboxesData, ListSandboxesResponses, CreateSandboxData, CreateSandboxResponses, CreateSandboxErrors, GetSandboxData, GetSandboxResponses, GetSandboxErrors, CmdData, CmdResponses, CmdErrors, GetCmdData, GetCmdResponses, GetCmdErrors, CmdLogsData, CmdLogsResponses, CmdLogsErrors, DestroySandboxData, DestroySandboxResponses, DestroySandboxErrors, DomainData, DomainResponses, DomainErrors, ExecData, ExecResponses, ExecErrors, ExecDetachedData, ExecDetachedResponses, ExecDetachedErrors, ExtendTimeoutData, ExtendTimeoutResponses, ExtendTimeoutErrors, MkdirData, MkdirResponses, MkdirErrors, ReadFileData, ReadFileResponses, ReadFileErrors, StatPathData, StatPathResponses, StatPathErrors, WriteFileData, WriteFileResponses, WriteFileErrors, WriteFilesData, WriteFilesResponses, WriteFilesErrors, ListJobsData, ListJobsResponses, ListJobsErrors, JobStatusData, JobStatusResponses, JobStatusErrors, KillJobData, KillJobResponses, KillJobErrors, JobLogsData, JobLogsResponses, JobLogsErrors, PauseSandboxData, PauseSandboxResponses, PauseSandboxErrors, ClearPreviewPasswordData, ClearPreviewPasswordResponses, ClearPreviewPasswordErrors, SetPreviewPasswordData, SetPreviewPasswordResponses, SetPreviewPasswordErrors, RestartSandboxData, RestartSandboxResponses, RestartSandboxErrors, ResumeSandboxData, ResumeSandboxResponses, ResumeSandboxErrors, SourceSandboxData, SourceSandboxResponses, SourceSandboxErrors, StopSandboxData, StopSandboxResponses, StopSandboxErrors, CmdKillData, CmdKillResponses, CmdKillErrors, GetVisitorSessionsData, GetVisitorSessionsResponses, GetVisitorSessionsErrors, DeleteSessionReplayData, DeleteSessionReplayResponses, DeleteSessionReplayErrors, GetSessionReplayData, GetSessionReplayResponses, GetSessionReplayErrors, UpdateSessionDurationData, UpdateSessionDurationResponses, UpdateSessionDurationErrors, GetSessionReplayEventsData, GetSessionReplayEventsResponses, GetSessionReplayEventsErrors, AddEventsData, AddEventsResponses, AddEventsErrors, DeleteScanData, DeleteScanResponses, DeleteScanErrors, GetScanData, GetScanResponses, GetScanErrors, GetScanVulnerabilitiesData, GetScanVulnerabilitiesResponses, GetScanVulnerabilitiesErrors, ListEventTypesData, ListEventTypesResponses, TriggerWeeklyDigestData, TriggerWeeklyDigestResponses, TriggerWeeklyDigestErrors, ListExternalPluginsData, ListExternalPluginsResponses, ReloadPluginsData, ReloadPluginsResponses, ReloadPluginsErrors, IngestSentryEnvelopeData, IngestSentryEnvelopeResponses, IngestSentryEnvelopeErrors, IngestSentryEventData, IngestSentryEventResponses, IngestSentryEventErrors, ListAuditLogsData, ListAuditLogsResponses, ListAuditLogsErrors, GetAuditLogData, GetAuditLogResponses, GetAuditLogErrors } from './types.gen'; +import type { GetPlatformInfoData, GetPlatformInfoResponses, GetPlatformInfoErrors, ChunkUploadOptionsData, ChunkUploadOptionsResponses, CreateReleaseData, CreateReleaseResponses, CreateReleaseErrors, CreateProjectReleaseData, CreateProjectReleaseResponses, CreateProjectReleaseErrors, FinalizeProjectReleaseData, FinalizeProjectReleaseResponses, FinalizeProjectReleaseErrors, ListReleaseFilesData, ListReleaseFilesResponses, ListReleaseFilesErrors, UploadReleaseFileData, UploadReleaseFileResponses, UploadReleaseFileErrors, RecordEventMetricsData, RecordEventMetricsResponses, RecordEventMetricsErrors, AddSessionReplayEventsData, AddSessionReplayEventsResponses, AddSessionReplayEventsErrors, InitSessionReplayData, InitSessionReplayResponses, InitSessionReplayErrors, RecordSpeedMetricsData, RecordSpeedMetricsResponses, RecordSpeedMetricsErrors, UpdateSpeedMetricsData, UpdateSpeedMetricsResponses, UpdateSpeedMetricsErrors, WebhookTriggerData, WebhookTriggerResponses, WebhookTriggerErrors, GetPricingData, GetPricingResponses, GetPricingErrors, ListProviderKeysData, ListProviderKeysResponses, ListProviderKeysErrors, CreateProviderKeyData, CreateProviderKeyResponses, CreateProviderKeyErrors, TestProviderKeyInlineData, TestProviderKeyInlineResponses, TestProviderKeyInlineErrors, DeleteProviderKeyData, DeleteProviderKeyResponses, DeleteProviderKeyErrors, UpdateProviderKeyData, UpdateProviderKeyResponses, UpdateProviderKeyErrors, TestProviderKeyByIdData, TestProviderKeyByIdResponses, TestProviderKeyByIdErrors, GetUsageByProviderData, GetUsageByProviderResponses, GetUsageByProviderErrors, GetConversationsData, GetConversationsResponses, GetConversationsErrors, GetConversationDetailData, GetConversationDetailResponses, GetConversationDetailErrors, GetUsageRecentData, GetUsageRecentResponses, GetUsageRecentErrors, GetUsageSummaryData, GetUsageSummaryResponses, GetUsageSummaryErrors, GetUsageTimeseriesData, GetUsageTimeseriesResponses, GetUsageTimeseriesErrors, GetUsageTopModelsData, GetUsageTopModelsResponses, GetUsageTopModelsErrors, ChatCompletionsData, ChatCompletionsResponses, ChatCompletionsErrors, EmbeddingsData, EmbeddingsResponses, EmbeddingsErrors, ListModelsData, ListModelsResponses, ListModelsErrors, GetAnalyticsActiveVisitorsData, GetAnalyticsActiveVisitorsResponses, GetAnalyticsActiveVisitorsErrors, GetEventDetailData, GetEventDetailResponses, GetEventDetailErrors, GetEventVisitorsData, GetEventVisitorsResponses, GetEventVisitorsErrors, GetAnalyticsEventsCountData, GetAnalyticsEventsCountResponses, GetAnalyticsEventsCountErrors, GetGeneralStatsData, GetGeneralStatsResponses, GetGeneralStatsErrors, GetLiveVisitorsListData, GetLiveVisitorsListResponses, GetLiveVisitorsListErrors, GetPageFlowData, GetPageFlowResponses, GetPageFlowErrors, GetPageHourlySessionsData, GetPageHourlySessionsResponses, GetPageHourlySessionsErrors, GetPagePathDetailData, GetPagePathDetailResponses, GetPagePathDetailErrors, GetPagePathVisitorsData, GetPagePathVisitorsResponses, GetPagePathVisitorsErrors, GetPagePathsData, GetPagePathsResponses, GetPagePathsErrors, GetPagePathsSparklinesData, GetPagePathsSparklinesResponses, GetPagePathsSparklinesErrors, GetRecentActivityData, GetRecentActivityResponses, GetRecentActivityErrors, GetSessionDetailsData, GetSessionDetailsResponses, GetSessionDetailsErrors, GetAnalyticsSessionEventsData, GetAnalyticsSessionEventsResponses, GetAnalyticsSessionEventsErrors, GetSessionLogsData, GetSessionLogsResponses, GetSessionLogsErrors, GetVisitorsData, GetVisitorsResponses, GetVisitorsErrors, GetVisitorByGuidData, GetVisitorByGuidResponses, GetVisitorByGuidErrors, GetVisitorByIdData, GetVisitorByIdResponses, GetVisitorByIdErrors, GetVisitorDetailsData, GetVisitorDetailsResponses, GetVisitorDetailsErrors, EnrichVisitorData, EnrichVisitorResponses, EnrichVisitorErrors, GetVisitorInfoData, GetVisitorInfoResponses, GetVisitorInfoErrors, GetVisitorJourneyData, GetVisitorJourneyResponses, GetVisitorJourneyErrors, GetAnalyticsVisitorSessionsData, GetAnalyticsVisitorSessionsResponses, GetAnalyticsVisitorSessionsErrors, GetVisitorStatsData, GetVisitorStatsResponses, GetVisitorStatsErrors, ListApiKeysData, ListApiKeysResponses, ListApiKeysErrors, CreateApiKeyData, CreateApiKeyResponses, CreateApiKeyErrors, GetApiKeyPermissionsData, GetApiKeyPermissionsResponses, GetApiKeyPermissionsErrors, DeleteApiKeyData, DeleteApiKeyResponses, DeleteApiKeyErrors, GetApiKeyData, GetApiKeyResponses, GetApiKeyErrors, UpdateApiKeyData, UpdateApiKeyResponses, UpdateApiKeyErrors, ActivateApiKeyData, ActivateApiKeyResponses, ActivateApiKeyErrors, DeactivateApiKeyData, DeactivateApiKeyResponses, DeactivateApiKeyErrors, CliLoginData, CliLoginResponses, CliLoginErrors, CliLogoutData, CliLogoutResponses, CliLogoutErrors, EmailStatusData, EmailStatusResponses, EmailStatusErrors, LoginData, LoginResponses, LoginErrors, RequestMagicLinkData, RequestMagicLinkResponses, RequestMagicLinkErrors, VerifyMagicLinkData, VerifyMagicLinkResponses, VerifyMagicLinkErrors, RequestPasswordResetData, RequestPasswordResetResponses, RequestPasswordResetErrors, ResetPasswordData, ResetPasswordResponses, ResetPasswordErrors, VerifyEmailData, VerifyEmailResponses, VerifyEmailErrors, VerifyMfaChallengeData, VerifyMfaChallengeResponses, VerifyMfaChallengeErrors, RunExternalServiceBackupData, RunExternalServiceBackupResponses, RunExternalServiceBackupErrors, ListS3SourcesData, ListS3SourcesResponses, ListS3SourcesErrors, CreateS3SourceData, CreateS3SourceResponses, CreateS3SourceErrors, TestS3ConnectionPreviewData, TestS3ConnectionPreviewResponses, TestS3ConnectionPreviewErrors, DeleteS3SourceData, DeleteS3SourceResponses, DeleteS3SourceErrors, GetS3SourceData, GetS3SourceResponses, GetS3SourceErrors, UpdateS3SourceData, UpdateS3SourceResponses, UpdateS3SourceErrors, ListSourceBackupsData, ListSourceBackupsResponses, ListSourceBackupsErrors, RunBackupForSourceData, RunBackupForSourceResponses, RunBackupForSourceErrors, SetDefaultS3SourceData, SetDefaultS3SourceResponses, SetDefaultS3SourceErrors, TestS3SourceConnectionData, TestS3SourceConnectionResponses, TestS3SourceConnectionErrors, ListBackupSchedulesData, ListBackupSchedulesResponses, ListBackupSchedulesErrors, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateBackupScheduleErrors, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteBackupScheduleErrors, GetBackupScheduleData, GetBackupScheduleResponses, GetBackupScheduleErrors, ListBackupsForScheduleData, ListBackupsForScheduleResponses, ListBackupsForScheduleErrors, DisableBackupScheduleData, DisableBackupScheduleResponses, DisableBackupScheduleErrors, EnableBackupScheduleData, EnableBackupScheduleResponses, EnableBackupScheduleErrors, GetBackupData, GetBackupResponses, GetBackupErrors, BlobDeleteData, BlobDeleteResponses, BlobDeleteErrors, BlobListData, BlobListResponses, BlobListErrors, BlobPutData, BlobPutResponses, BlobPutErrors, BlobCopyData, BlobCopyResponses, BlobCopyErrors, BlobDisableData, BlobDisableResponses, BlobDisableErrors, BlobEnableData, BlobEnableResponses, BlobEnableErrors, BlobStatusData, BlobStatusResponses, BlobStatusErrors, BlobUpdateData, BlobUpdateResponses, BlobUpdateErrors, BlobDownloadData, BlobDownloadResponses, BlobDownloadErrors, BlobHeadData, BlobHeadResponses, BlobHeadErrors, GetDashboardProjectsAnalyticsData, GetDashboardProjectsAnalyticsResponses, GetDashboardProjectsAnalyticsErrors, GetActivityGraphData, GetActivityGraphResponses, GetActivityGraphErrors, GetScanByDeploymentData, GetScanByDeploymentResponses, GetScanByDeploymentErrors, ListDnsProvidersData, ListDnsProvidersResponses, ListDnsProvidersErrors, CreateDnsProviderData, CreateDnsProviderResponses, CreateDnsProviderErrors, DeleteDnsProviderData, DeleteDnsProviderResponses, DeleteDnsProviderErrors, GetDnsProviderData, GetDnsProviderResponses, GetDnsProviderErrors, UpdateProviderData, UpdateProviderResponses, UpdateProviderErrors, ListManagedDomainsData, ListManagedDomainsResponses, ListManagedDomainsErrors, AddManagedDomainData, AddManagedDomainResponses, AddManagedDomainErrors, TestProviderConnectionData, TestProviderConnectionResponses, TestProviderConnectionErrors, ListProviderZonesData, ListProviderZonesResponses, ListProviderZonesErrors, RemoveManagedDomainData, RemoveManagedDomainResponses, RemoveManagedDomainErrors, VerifyManagedDomainData, VerifyManagedDomainResponses, VerifyManagedDomainErrors, LookupDnsARecordsData, LookupDnsARecordsResponses, LookupDnsARecordsErrors, ListDomainsData, ListDomainsResponses, ListDomainsErrors, CreateDomainData, CreateDomainResponses, CreateDomainErrors, GetDomainByHostData, GetDomainByHostResponses, GetDomainByHostErrors, CancelDomainOrderData, CancelDomainOrderResponses, CancelDomainOrderErrors, GetDomainOrderData, GetDomainOrderResponses, GetDomainOrderErrors, CreateOrRecreateOrderData, CreateOrRecreateOrderResponses, CreateOrRecreateOrderErrors, FinalizeOrderData, FinalizeOrderResponses, FinalizeOrderErrors, SetupDnsChallengeData, SetupDnsChallengeResponses, SetupDnsChallengeErrors, DeleteDomainData, DeleteDomainResponses, DeleteDomainErrors, GetDomainByIdData, GetDomainByIdResponses, GetDomainByIdErrors, GetChallengeTokenData, GetChallengeTokenResponses, GetChallengeTokenErrors, GetHttpChallengeDebugData, GetHttpChallengeDebugResponses, GetHttpChallengeDebugErrors, ProvisionDomainData, ProvisionDomainResponses, ProvisionDomainErrors, RenewDomainData, RenewDomainResponses, RenewDomainErrors, CheckDomainStatusData, CheckDomainStatusResponses, CheckDomainStatusErrors, ListEmailDomainsData, ListEmailDomainsResponses, ListEmailDomainsErrors, CreateEmailDomainData, CreateEmailDomainResponses, CreateEmailDomainErrors, GetDomainByNameData, GetDomainByNameResponses, GetDomainByNameErrors, DeleteEmailDomainData, DeleteEmailDomainResponses, DeleteEmailDomainErrors, GetDomainData, GetDomainResponses, GetDomainErrors, GetDomainDnsRecordsData, GetDomainDnsRecordsResponses, GetDomainDnsRecordsErrors, SetupDnsData, SetupDnsResponses, SetupDnsErrors, VerifyDomainData, VerifyDomainResponses, VerifyDomainErrors, ListEmailProvidersData, ListEmailProvidersResponses, ListEmailProvidersErrors, CreateEmailProviderData, CreateEmailProviderResponses, CreateEmailProviderErrors, DeleteEmailProviderData, DeleteEmailProviderResponses, DeleteEmailProviderErrors, GetEmailProviderData, GetEmailProviderResponses, GetEmailProviderErrors, TestProviderData, TestProviderResponses, TestProviderErrors, ListEmailsData, ListEmailsResponses, ListEmailsErrors, SendEmailData, SendEmailResponses, SendEmailErrors, GetGlobalEventsData, GetGlobalEventsResponses, GetGlobalEventsErrors, GetGlobalEventStatsData, GetGlobalEventStatsResponses, GetGlobalEventStatsErrors, GetEmailStatsData, GetEmailStatsResponses, GetEmailStatsErrors, ValidateEmailData, ValidateEmailResponses, ValidateEmailErrors, TrackClickData, TrackClickErrors, TrackOpenData, TrackOpenResponses, TrackOpenErrors, GetEmailData, GetEmailResponses, GetEmailErrors, GetEmailTrackingData, GetEmailTrackingResponses, GetEmailTrackingErrors, GetEmailEventsData, GetEmailEventsResponses, GetEmailEventsErrors, GetEmailLinksData, GetEmailLinksResponses, GetEmailLinksErrors, ListServicesData, ListServicesResponses, ListServicesErrors, CreateServiceData, CreateServiceResponses, CreateServiceErrors, ListAvailableContainersData, ListAvailableContainersResponses, ListAvailableContainersErrors, GetServiceBySlugData, GetServiceBySlugResponses, GetServiceBySlugErrors, ListServiceHealthStatusesData, ListServiceHealthStatusesResponses, ListServiceHealthStatusesErrors, ImportExternalServiceData, ImportExternalServiceResponses, ImportExternalServiceErrors, ListProjectServicesData, ListProjectServicesResponses, ListProjectServicesErrors, GetProjectServiceEnvironmentVariablesData, GetProjectServiceEnvironmentVariablesResponses, GetProjectServiceEnvironmentVariablesErrors, GetProvidersMetadataData, GetProvidersMetadataResponses, GetProvidersMetadataErrors, GetProviderMetadataData, GetProviderMetadataResponses, GetProviderMetadataErrors, GetServiceTypesData, GetServiceTypesResponses, GetServiceTypesErrors, GetServiceTypeParametersData, GetServiceTypeParametersResponses, GetServiceTypeParametersErrors, DeleteServiceData, DeleteServiceResponses, DeleteServiceErrors, GetServiceData, GetServiceResponses, GetServiceErrors, UpdateServiceData, UpdateServiceResponses, UpdateServiceErrors, GetClusterHealthData, GetClusterHealthResponses, GetClusterHealthErrors, TriggerServiceHealthCheckData, TriggerServiceHealthCheckResponses, TriggerServiceHealthCheckErrors, GetServiceHealthStatusData, GetServiceHealthStatusResponses, GetServiceHealthStatusErrors, AddClusterMemberData, AddClusterMemberResponses, AddClusterMemberErrors, RemoveClusterMemberData, RemoveClusterMemberResponses, RemoveClusterMemberErrors, GetClusterMemberData, GetClusterMemberResponses, GetClusterMemberErrors, PromoteClusterMemberData, PromoteClusterMemberResponses, PromoteClusterMemberErrors, GetServicePreviewEnvironmentVariablesMaskedData, GetServicePreviewEnvironmentVariablesMaskedResponses, GetServicePreviewEnvironmentVariablesMaskedErrors, GetServicePreviewEnvironmentVariableNamesData, GetServicePreviewEnvironmentVariableNamesResponses, GetServicePreviewEnvironmentVariableNamesErrors, ListServiceProjectsData, ListServiceProjectsResponses, ListServiceProjectsErrors, LinkServiceToProjectData, LinkServiceToProjectResponses, LinkServiceToProjectErrors, UnlinkServiceFromProjectData, UnlinkServiceFromProjectResponses, UnlinkServiceFromProjectErrors, GetServiceEnvironmentVariablesData, GetServiceEnvironmentVariablesResponses, GetServiceEnvironmentVariablesErrors, GetServiceEnvironmentVariableData, GetServiceEnvironmentVariableResponses, GetServiceEnvironmentVariableErrors, StartRestoreData, StartRestoreResponses, StartRestoreErrors, GetRestoreCapabilitiesData, GetRestoreCapabilitiesResponses, GetRestoreCapabilitiesErrors, PlanRestoreData, PlanRestoreResponses, PlanRestoreErrors, ListRestoreRunsForServiceData, ListRestoreRunsForServiceResponses, RetryClusterData, RetryClusterResponses, RetryClusterErrors, StartServiceData, StartServiceResponses, StartServiceErrors, StopServiceData, StopServiceResponses, StopServiceErrors, UpgradeServiceData, UpgradeServiceResponses, UpgradeServiceErrors, ListRootContainersData, ListRootContainersResponses, ListRootContainersErrors, ListContainersAtPathData, ListContainersAtPathResponses, ListContainersAtPathErrors, ListEntitiesData, ListEntitiesResponses, ListEntitiesErrors, GetEntityInfoData, GetEntityInfoResponses, GetEntityInfoErrors, QueryDataData, QueryDataResponses, QueryDataErrors, DownloadObjectData, DownloadObjectResponses, DownloadObjectErrors, GetContainerInfoData, GetContainerInfoResponses, GetContainerInfoErrors, CheckExplorerSupportData, CheckExplorerSupportResponses, CheckExplorerSupportErrors, ListPgUpgradesData, ListPgUpgradesResponses, ListPgUpgradesErrors, StartPgUpgradeData, StartPgUpgradeResponses, StartPgUpgradeErrors, GetPgUpgradeData, GetPgUpgradeResponses, GetPgUpgradeErrors, CancelPgUpgradeData, CancelPgUpgradeResponses, CancelPgUpgradeErrors, GetPgUpgradeLogsData, GetPgUpgradeLogsResponses, GetPgUpgradeLogsErrors, RetryPgUpgradeData, RetryPgUpgradeResponses, RetryPgUpgradeErrors, RollbackPgUpgradeData, RollbackPgUpgradeResponses, RollbackPgUpgradeErrors, GetFileData, GetFileResponses, GetFileErrors, GetIpGeolocationData, GetIpGeolocationResponses, GetIpGeolocationErrors, ListConnectionsData, ListConnectionsResponses, ListConnectionsErrors, DeleteConnectionData, DeleteConnectionResponses, DeleteConnectionErrors, ActivateConnectionData, ActivateConnectionResponses, ActivateConnectionErrors, DeactivateConnectionData, DeactivateConnectionResponses, DeactivateConnectionErrors, RunConnectionHealthCheckData, RunConnectionHealthCheckResponses, RunConnectionHealthCheckErrors, ListRepositoriesByConnectionData, ListRepositoriesByConnectionResponses, ListRepositoriesByConnectionErrors, SyncRepositoriesData, SyncRepositoriesResponses, SyncRepositoriesErrors, UpdateConnectionTokenData, UpdateConnectionTokenResponses, UpdateConnectionTokenErrors, ValidateConnectionData, ValidateConnectionResponses, ValidateConnectionErrors, ListGitProvidersData, ListGitProvidersResponses, ListGitProvidersErrors, CreateGitProviderData, CreateGitProviderResponses, CreateGitProviderErrors, CreateGithubPatProviderData, CreateGithubPatProviderResponses, CreateGithubPatProviderErrors, CreateGitlabOauthProviderData, CreateGitlabOauthProviderResponses, CreateGitlabOauthProviderErrors, CreateGitlabPatProviderData, CreateGitlabPatProviderResponses, CreateGitlabPatProviderErrors, DeleteGitProviderData, DeleteGitProviderResponses, DeleteGitProviderErrors, GetGitProviderData, GetGitProviderResponses, GetGitProviderErrors, ActivateProviderData, ActivateProviderResponses, ActivateProviderErrors, HandleGitProviderOauthCallbackData, HandleGitProviderOauthCallbackErrors, GetProviderConnectionsData, GetProviderConnectionsResponses, GetProviderConnectionsErrors, UpdateGitProviderCredentialsData, UpdateGitProviderCredentialsResponses, UpdateGitProviderCredentialsErrors, DeactivateProviderData, DeactivateProviderResponses, DeactivateProviderErrors, CheckProviderDeletionSafetyData, CheckProviderDeletionSafetyResponses, CheckProviderDeletionSafetyErrors, StartGitProviderOauthData, StartGitProviderOauthErrors, DeleteProviderSafelyData, DeleteProviderSafelyResponses, DeleteProviderSafelyErrors, GetPublicRepositoryData, GetPublicRepositoryResponses, GetPublicRepositoryErrors, GetPublicBranchesData, GetPublicBranchesResponses, GetPublicBranchesErrors, DetectPublicPresetsData, DetectPublicPresetsResponses, DetectPublicPresetsErrors, DiscoverWorkloadsData, DiscoverWorkloadsResponses, DiscoverWorkloadsErrors, ExecuteImportData, ExecuteImportResponses, ExecuteImportErrors, CreatePlanData, CreatePlanResponses, CreatePlanErrors, ListSourcesData, ListSourcesResponses, ListSourcesErrors, GetImportStatusData, GetImportStatusResponses, GetImportStatusErrors, GetIncidentData, GetIncidentResponses, GetIncidentErrors, UpdateIncidentStatusData, UpdateIncidentStatusResponses, UpdateIncidentStatusErrors, GetIncidentUpdatesData, GetIncidentUpdatesResponses, GetIncidentUpdatesErrors, AdminListNodesData, AdminListNodesResponses, AdminListNodesErrors, RegisterNodeData, RegisterNodeResponses, RegisterNodeErrors, AdminRemoveNodeData, AdminRemoveNodeResponses, AdminRemoveNodeErrors, AdminGetNodeData, AdminGetNodeResponses, AdminGetNodeErrors, AdminListNodeContainersData, AdminListNodeContainersResponses, AdminListNodeContainersErrors, PostDnsAckData, PostDnsAckResponses, PostDnsAckErrors, GetDnsChangesData, GetDnsChangesResponses, GetDnsChangesErrors, AdminUndrainNodeData, AdminUndrainNodeResponses, AdminUndrainNodeErrors, AdminDrainStatusData, AdminDrainStatusResponses, AdminDrainStatusErrors, AdminDrainNodeData, AdminDrainNodeResponses, AdminDrainNodeErrors, NodeHeartbeatData, NodeHeartbeatResponses, NodeHeartbeatErrors, ListPeersData, ListPeersResponses, ListPeersErrors, GetS3CredentialsData, GetS3CredentialsResponses, GetS3CredentialsErrors, ListIpAccessControlData, ListIpAccessControlResponses, ListIpAccessControlErrors, CreateIpAccessControlData, CreateIpAccessControlResponses, CreateIpAccessControlErrors, CheckIpBlockedData, CheckIpBlockedResponses, CheckIpBlockedErrors, DeleteIpAccessControlData, DeleteIpAccessControlResponses, DeleteIpAccessControlErrors, GetIpAccessControlData, GetIpAccessControlResponses, GetIpAccessControlErrors, UpdateIpAccessControlData, UpdateIpAccessControlResponses, UpdateIpAccessControlErrors, KvDelData, KvDelResponses, KvDelErrors, KvDisableData, KvDisableResponses, KvDisableErrors, KvEnableData, KvEnableResponses, KvEnableErrors, KvExpireData, KvExpireResponses, KvExpireErrors, KvGetData, KvGetResponses, KvGetErrors, KvIncrData, KvIncrResponses, KvIncrErrors, KvKeysData, KvKeysResponses, KvKeysErrors, KvSetData, KvSetResponses, KvSetErrors, KvStatusData, KvStatusResponses, KvStatusErrors, KvTtlData, KvTtlResponses, KvTtlErrors, KvUpdateData, KvUpdateResponses, KvUpdateErrors, ListRoutesData, ListRoutesResponses, ListRoutesErrors, CreateRouteData, CreateRouteResponses, CreateRouteErrors, DeleteRouteData, DeleteRouteResponses, DeleteRouteErrors, GetRouteData, GetRouteResponses, GetRouteErrors, UpdateRouteData, UpdateRouteResponses, UpdateRouteErrors, LogoutData, LogoutResponses, LogoutErrors, GetLogContextData, GetLogContextResponses, GetLogContextErrors, SearchLogsData, SearchLogsResponses, SearchLogsErrors, TailLogsData, TailLogsResponses, TailLogsErrors, GetProjectsMonitorHealthData, GetProjectsMonitorHealthResponses, GetProjectsMonitorHealthErrors, DeleteMonitorData, DeleteMonitorResponses, DeleteMonitorErrors, GetMonitorData, GetMonitorResponses, GetMonitorErrors, GetBucketedStatusData, GetBucketedStatusResponses, GetBucketedStatusErrors, GetCurrentMonitorStatusData, GetCurrentMonitorStatusResponses, GetCurrentMonitorStatusErrors, GetUptimeHistoryData, GetUptimeHistoryResponses, GetUptimeHistoryErrors, DeletePreferencesData, DeletePreferencesResponses, DeletePreferencesErrors, GetPreferencesData, GetPreferencesResponses, GetPreferencesErrors, UpdatePreferencesData, UpdatePreferencesResponses, UpdatePreferencesErrors, ListNotificationProvidersData, ListNotificationProvidersResponses, ListNotificationProvidersErrors, CreateNotificationProviderData, CreateNotificationProviderResponses, CreateNotificationProviderErrors, CreateNotificationEmailProviderData, CreateNotificationEmailProviderResponses, CreateNotificationEmailProviderErrors, UpdateEmailProviderData, UpdateEmailProviderResponses, UpdateEmailProviderErrors, CreateSlackProviderData, CreateSlackProviderResponses, CreateSlackProviderErrors, UpdateSlackProviderData, UpdateSlackProviderResponses, UpdateSlackProviderErrors, CreateWebhookProviderData, CreateWebhookProviderResponses, CreateWebhookProviderErrors, UpdateWebhookProviderData, UpdateWebhookProviderResponses, UpdateWebhookProviderErrors, DeleteNotificationProviderData, DeleteNotificationProviderResponses, DeleteNotificationProviderErrors, GetNotificationProviderData, GetNotificationProviderResponses, GetNotificationProviderErrors, UpdateNotificationProviderData, UpdateNotificationProviderResponses, UpdateNotificationProviderErrors, TestNotificationProviderData, TestNotificationProviderResponses, TestNotificationProviderErrors, ListOrdersData, ListOrdersResponses, ListOrdersErrors, QueryGenaiTracesData, QueryGenaiTracesResponses, QueryGenaiTracesErrors, GetGenaiTraceData, GetGenaiTraceResponses, GetGenaiTraceErrors, GetHealthData, GetHealthResponses, GetHealthErrors, ListInsightsData, ListInsightsResponses, ListInsightsErrors, QueryLogsData, QueryLogsResponses, QueryLogsErrors, ListMetricNamesData, ListMetricNamesResponses, ListMetricNamesErrors, QueryMetricsData, QueryMetricsResponses, QueryMetricsErrors, GetPipelineStatsData, GetPipelineStatsResponses, GetPipelineStatsErrors, GetQuotaData, GetQuotaResponses, GetQuotaErrors, QueryTraceSummariesData, QueryTraceSummariesResponses, QueryTraceSummariesErrors, QueryTracesData, QueryTracesResponses, QueryTracesErrors, GetTraceData, GetTraceResponses, GetTraceErrors, IngestLogsData, IngestLogsResponses, IngestLogsErrors, IngestMetricsData, IngestMetricsResponses, IngestMetricsErrors, IngestTracesData, IngestTracesResponses, IngestTracesErrors, IngestLogsByPathData, IngestLogsByPathResponses, IngestLogsByPathErrors, IngestMetricsByPathData, IngestMetricsByPathResponses, IngestMetricsByPathErrors, IngestTracesByPathData, IngestTracesByPathResponses, IngestTracesByPathErrors, HasPerformanceMetricsData, HasPerformanceMetricsResponses, HasPerformanceMetricsErrors, GetPerformanceMetricsData, GetPerformanceMetricsResponses, GetPerformanceMetricsErrors, GetMetricsOverTimeData, GetMetricsOverTimeResponses, GetMetricsOverTimeErrors, GetGroupedPageMetricsData, GetGroupedPageMetricsResponses, GetGroupedPageMetricsErrors, GetAccessInfoData, GetAccessInfoResponses, GetAccessInfoErrors, GetPrivateIpData, GetPrivateIpResponses, GetPrivateIpErrors, GetPublicIpData, GetPublicIpResponses, GetPublicIpErrors, ListPresetsData, ListPresetsResponses, ListPresetsErrors, GeneratePresetDockerfileData, GeneratePresetDockerfileResponses, GeneratePresetDockerfileErrors, GetPreviewGatewayLogsData, GetPreviewGatewayLogsResponses, RestartPreviewGatewayData, RestartPreviewGatewayResponses, GetPreviewGatewaySettingsData, GetPreviewGatewaySettingsResponses, PatchPreviewGatewaySettingsData, PatchPreviewGatewaySettingsResponses, GetPreviewGatewayStatusData, GetPreviewGatewayStatusResponses, UpgradePreviewGatewayData, UpgradePreviewGatewayResponses, GetProjectsData, GetProjectsResponses, GetProjectsErrors, CreateProjectData, CreateProjectResponses, CreateProjectErrors, GetProjectBySlugData, GetProjectBySlugResponses, GetProjectBySlugErrors, CreateProjectFromTemplateData, CreateProjectFromTemplateResponses, CreateProjectFromTemplateErrors, GetProjectStatisticsData, GetProjectStatisticsResponses, GetProjectStatisticsErrors, DeleteProjectData, DeleteProjectResponses, DeleteProjectErrors, GetProjectData, GetProjectResponses, GetProjectErrors, UpdateProjectData, UpdateProjectResponses, UpdateProjectErrors, GetProjectDeploymentsData, GetProjectDeploymentsResponses, GetProjectDeploymentsErrors, GetLastDeploymentData, GetLastDeploymentResponses, GetLastDeploymentErrors, TriggerProjectPipelineData, TriggerProjectPipelineResponses, TriggerProjectPipelineErrors, GetActiveVisitorsData, GetActiveVisitorsResponses, GetActiveVisitorsErrors, ListAgentsData, ListAgentsResponses, ListAgentsErrors, CreateAgentData, CreateAgentResponses, CreateAgentErrors, GetCliStatusData, GetCliStatusResponses, GetCliStatusErrors, ListAllRunsData, ListAllRunsResponses, ListAllRunsErrors, LatestRunForSourceData, LatestRunForSourceResponses, LatestRunForSourceErrors, GetRunWithLogsData, GetRunWithLogsResponses, GetRunWithLogsErrors, CancelRunData, CancelRunResponses, CancelRunErrors, RetryRunData, RetryRunResponses, RetryRunErrors, StreamRunEventsData, StreamRunEventsResponses, StreamRunEventsErrors, GetSandboxStatusData, GetSandboxStatusResponses, GetSandboxStatusErrors, SmokeTestAgentData, SmokeTestAgentResponses, SmokeTestAgentErrors, DeleteAgentData, DeleteAgentResponses, DeleteAgentErrors, GetAgentData, GetAgentResponses, GetAgentErrors, UpdateAgentData, UpdateAgentResponses, UpdateAgentErrors, ListAgentRunsData, ListAgentRunsResponses, ListAgentRunsErrors, TriggerAgentData, TriggerAgentResponses, TriggerAgentErrors, GetAggregatedBucketsData, GetAggregatedBucketsResponses, GetAggregatedBucketsErrors, StartAnalysisData, StartAnalysisResponses, StartAnalysisErrors, GetRunData, GetRunResponses, GetRunErrors, AddContextData, AddContextResponses, AddContextErrors, CancelData, CancelResponses, CancelErrors, CreatePrData, CreatePrResponses, CreatePrErrors, StartFixData, StartFixResponses, StartFixErrors, ReAnalyzeData, ReAnalyzeResponses, ReAnalyzeErrors, StreamEventsData, StreamEventsResponses, StreamEventsErrors, UpdateAutomaticDeployData, UpdateAutomaticDeployResponses, UpdateAutomaticDeployErrors, ListCustomDomainsForProjectData, ListCustomDomainsForProjectResponses, ListCustomDomainsForProjectErrors, CreateCustomDomainData, CreateCustomDomainResponses, CreateCustomDomainErrors, DeleteCustomDomainData, DeleteCustomDomainResponses, DeleteCustomDomainErrors, GetCustomDomainData, GetCustomDomainResponses, GetCustomDomainErrors, UpdateCustomDomainData, UpdateCustomDomainResponses, UpdateCustomDomainErrors, LinkCustomDomainToCertificateData, LinkCustomDomainToCertificateResponses, LinkCustomDomainToCertificateErrors, UpdateProjectDeploymentConfigData, UpdateProjectDeploymentConfigResponses, UpdateProjectDeploymentConfigErrors, GetDeploymentData, GetDeploymentResponses, GetDeploymentErrors, CancelDeploymentData, CancelDeploymentResponses, CancelDeploymentErrors, GetDeploymentJobsData, GetDeploymentJobsResponses, GetDeploymentJobsErrors, GetDeploymentJobLogsData, GetDeploymentJobLogsResponses, GetDeploymentJobLogsErrors, TailDeploymentJobLogsData, TailDeploymentJobLogsErrors, GetDeploymentOperationsData, GetDeploymentOperationsResponses, GetDeploymentOperationsErrors, ExecuteDeploymentOperationData, ExecuteDeploymentOperationResponses, ExecuteDeploymentOperationErrors, GetDeploymentOperationStatusData, GetDeploymentOperationStatusResponses, GetDeploymentOperationStatusErrors, PauseDeploymentData, PauseDeploymentResponses, PauseDeploymentErrors, PromoteDeploymentData, PromoteDeploymentResponses, PromoteDeploymentErrors, ResumeDeploymentData, ResumeDeploymentResponses, ResumeDeploymentErrors, RollbackToDeploymentData, RollbackToDeploymentResponses, RollbackToDeploymentErrors, TeardownDeploymentData, TeardownDeploymentResponses, TeardownDeploymentErrors, ListDsnsData, ListDsnsResponses, CreateDsnData, CreateDsnResponses, CreateDsnErrors, GetOrCreateDsnData, GetOrCreateDsnResponses, GetOrCreateDsnErrors, RegenerateDsnData, RegenerateDsnResponses, RegenerateDsnErrors, RevokeDsnData, RevokeDsnResponses, RevokeDsnErrors, GetEnvironmentVariablesData, GetEnvironmentVariablesResponses, GetEnvironmentVariablesErrors, CreateEnvironmentVariableData, CreateEnvironmentVariableResponses, CreateEnvironmentVariableErrors, GetResolvedEnvironmentVariablesData, GetResolvedEnvironmentVariablesResponses, GetResolvedEnvironmentVariablesErrors, GetResolvedEnvironmentVariableValueData, GetResolvedEnvironmentVariableValueResponses, GetResolvedEnvironmentVariableValueErrors, GetEnvironmentVariableValueData, GetEnvironmentVariableValueResponses, GetEnvironmentVariableValueErrors, DeleteEnvironmentVariableData, DeleteEnvironmentVariableResponses, DeleteEnvironmentVariableErrors, UpdateEnvironmentVariableData, UpdateEnvironmentVariableResponses, UpdateEnvironmentVariableErrors, GetEnvironmentsData, GetEnvironmentsResponses, GetEnvironmentsErrors, CreateEnvironmentData, CreateEnvironmentResponses, CreateEnvironmentErrors, DeleteEnvironmentData, DeleteEnvironmentResponses, DeleteEnvironmentErrors, GetEnvironmentData, GetEnvironmentResponses, GetEnvironmentErrors, GetEnvironmentCronsData, GetEnvironmentCronsResponses, GetEnvironmentCronsErrors, GetCronByIdData, GetCronByIdResponses, GetCronByIdErrors, GetCronExecutionsData, GetCronExecutionsResponses, GetCronExecutionsErrors, GetEnvironmentDomainsData, GetEnvironmentDomainsResponses, GetEnvironmentDomainsErrors, AddEnvironmentDomainData, AddEnvironmentDomainResponses, AddEnvironmentDomainErrors, DeleteEnvironmentDomainData, DeleteEnvironmentDomainResponses, DeleteEnvironmentDomainErrors, UpdateEnvironmentSettingsData, UpdateEnvironmentSettingsResponses, UpdateEnvironmentSettingsErrors, SleepEnvironmentData, SleepEnvironmentResponses, SleepEnvironmentErrors, TeardownEnvironmentData, TeardownEnvironmentResponses, TeardownEnvironmentErrors, WakeEnvironmentData, WakeEnvironmentResponses, WakeEnvironmentErrors, GetContainerLogsData, GetContainerLogsErrors, ListContainersData, ListContainersResponses, ListContainersErrors, GetContainerDetailData, GetContainerDetailResponses, GetContainerDetailErrors, GetContainerLogsByIdData, GetContainerLogsByIdErrors, GetContainerMetricsData, GetContainerMetricsResponses, GetContainerMetricsErrors, StreamContainerMetricsData, StreamContainerMetricsResponses, StreamContainerMetricsErrors, RestartContainerData, RestartContainerResponses, RestartContainerErrors, StartContainerData, StartContainerResponses, StartContainerErrors, StopContainerData, StopContainerResponses, StopContainerErrors, DeployFromImageData, DeployFromImageResponses, DeployFromImageErrors, DeployFromImageUploadData, DeployFromImageUploadResponses, DeployFromImageUploadErrors, DeployFromStaticData, DeployFromStaticResponses, DeployFromStaticErrors, ListAlertRulesData, ListAlertRulesResponses, ListAlertRulesErrors, CreateAlertRuleData, CreateAlertRuleResponses, CreateAlertRuleErrors, DeleteAlertRuleData, DeleteAlertRuleResponses, DeleteAlertRuleErrors, GetAlertRuleData, GetAlertRuleResponses, GetAlertRuleErrors, UpdateAlertRuleData, UpdateAlertRuleResponses, UpdateAlertRuleErrors, GetErrorDashboardStatsData, GetErrorDashboardStatsResponses, GetErrorDashboardStatsErrors, ListErrorGroupsData, ListErrorGroupsResponses, ListErrorGroupsErrors, GetErrorGroupData, GetErrorGroupResponses, GetErrorGroupErrors, UpdateErrorGroupData, UpdateErrorGroupResponses, UpdateErrorGroupErrors, ListErrorEventsData, ListErrorEventsResponses, ListErrorEventsErrors, GetErrorEventData, GetErrorEventResponses, GetErrorEventErrors, GetErrorStatsData, GetErrorStatsResponses, GetErrorStatsErrors, GetErrorTimeSeriesData, GetErrorTimeSeriesResponses, GetErrorTimeSeriesErrors, GetEventsCountData, GetEventsCountResponses, GetEventsCountErrors, GetEventTypeBreakdownData, GetEventTypeBreakdownResponses, GetEventTypeBreakdownErrors, RecordConsoleEventData, RecordConsoleEventResponses, RecordConsoleEventErrors, GetPropertyBreakdownData, GetPropertyBreakdownResponses, GetPropertyBreakdownErrors, GetPropertyTimelineData, GetPropertyTimelineResponses, GetPropertyTimelineErrors, GetEventsTimelineData, GetEventsTimelineResponses, GetEventsTimelineErrors, GetUniqueEventsData, GetUniqueEventsResponses, GetUniqueEventsErrors, ListRemoteExternalImagesData, ListRemoteExternalImagesResponses, ListRemoteExternalImagesErrors, RegisterExternalImageData, RegisterExternalImageResponses, RegisterExternalImageErrors, DeleteExternalImageData, DeleteExternalImageResponses, DeleteExternalImageErrors, GetRemoteExternalImageData, GetRemoteExternalImageResponses, GetRemoteExternalImageErrors, ListFunnelsData, ListFunnelsResponses, ListFunnelsErrors, CreateFunnelData, CreateFunnelResponses, CreateFunnelErrors, PreviewFunnelMetricsData, PreviewFunnelMetricsResponses, PreviewFunnelMetricsErrors, DeleteFunnelData, DeleteFunnelResponses, DeleteFunnelErrors, UpdateFunnelData, UpdateFunnelResponses, UpdateFunnelErrors, GetFunnelMetricsData, GetFunnelMetricsResponses, GetFunnelMetricsErrors, UpdateGitSettingsData, UpdateGitSettingsResponses, UpdateGitSettingsErrors, ReinstallGitlabWebhookData, ReinstallGitlabWebhookResponses, ReinstallGitlabWebhookErrors, HasErrorGroupsData, HasErrorGroupsResponses, HasErrorGroupsErrors, HasAnalyticsEventsData, HasAnalyticsEventsResponses, HasAnalyticsEventsErrors, GetHourlyVisitsData, GetHourlyVisitsResponses, GetHourlyVisitsErrors, ListExternalImagesData, ListExternalImagesResponses, ListExternalImagesErrors, PushExternalImageData, PushExternalImageResponses, PushExternalImageErrors, GetExternalImageData, GetExternalImageResponses, GetExternalImageErrors, ListIncidentsData, ListIncidentsResponses, ListIncidentsErrors, CreateIncidentData, CreateIncidentResponses, CreateIncidentErrors, GetBucketedIncidentsData, GetBucketedIncidentsResponses, GetBucketedIncidentsErrors, PurgeProjectLogsData, PurgeProjectLogsResponses, PurgeProjectLogsErrors, ListMcpsData, ListMcpsResponses, ListMcpsErrors, CreateMcpData, CreateMcpResponses, CreateMcpErrors, DeleteMcpData, DeleteMcpResponses, DeleteMcpErrors, GetMcpData, GetMcpResponses, GetMcpErrors, UpdateMcpData, UpdateMcpResponses, UpdateMcpErrors, ListMonitorsData, ListMonitorsResponses, ListMonitorsErrors, CreateMonitorData, CreateMonitorResponses, CreateMonitorErrors, DeleteReleaseSourceMapsData, DeleteReleaseSourceMapsResponses, DeleteReleaseSourceMapsErrors, ListSourceMapsData, ListSourceMapsResponses, ListSourceMapsErrors, UploadSourceMapData, UploadSourceMapResponses, UploadSourceMapErrors, RevenueRecentEventsData, RevenueRecentEventsResponses, RevenueListIntegrationsData, RevenueListIntegrationsResponses, RevenueCreateIntegrationData, RevenueCreateIntegrationResponses, RevenueCreateIntegrationErrors, RevenueDeleteIntegrationData, RevenueDeleteIntegrationResponses, RevenueUpdateConfigData, RevenueUpdateConfigResponses, RevenueUpdateConfigErrors, RevenueImportInvoicesCsvData, RevenueImportInvoicesCsvResponses, RevenueImportInvoicesCsvErrors, RevenueImportSubscriptionsCsvData, RevenueImportSubscriptionsCsvResponses, RevenueImportSubscriptionsCsvErrors, RevenueRotateTokenData, RevenueRotateTokenResponses, RevenueUpdateSecretData, RevenueUpdateSecretResponses, RevenueUpdateSecretErrors, RevenueMetricsCustomersData, RevenueMetricsCustomersResponses, RevenueMetricsMrrData, RevenueMetricsMrrResponses, RevenueMetricsSummaryData, RevenueMetricsSummaryResponses, ListProjectSecretsData, ListProjectSecretsResponses, ListProjectSecretsErrors, CreateProjectSecretData, CreateProjectSecretResponses, CreateProjectSecretErrors, DeleteProjectSecretData, DeleteProjectSecretResponses, DeleteProjectSecretErrors, UpdateProjectSecretData, UpdateProjectSecretResponses, UpdateProjectSecretErrors, UpdateProjectSettingsData, UpdateProjectSettingsResponses, UpdateProjectSettingsErrors, ListSkillsData, ListSkillsResponses, ListSkillsErrors, CreateSkillData, CreateSkillResponses, CreateSkillErrors, UploadSkillData, UploadSkillResponses, UploadSkillErrors, DeleteSkillData, DeleteSkillResponses, DeleteSkillErrors, GetSkillData, GetSkillResponses, GetSkillErrors, UpdateSkillData, UpdateSkillResponses, UpdateSkillErrors, DownloadSkillArchiveData, DownloadSkillArchiveResponses, DownloadSkillArchiveErrors, ListReleasesData, ListReleasesResponses, ListReleasesErrors, DeleteSourceMapData, DeleteSourceMapResponses, DeleteSourceMapErrors, ListStaticBundlesData, ListStaticBundlesResponses, ListStaticBundlesErrors, DeleteStaticBundleData, DeleteStaticBundleResponses, DeleteStaticBundleErrors, GetStaticBundleData, GetStaticBundleResponses, GetStaticBundleErrors, GetStatusOverviewData, GetStatusOverviewResponses, GetStatusOverviewErrors, GetUniqueCountsData, GetUniqueCountsResponses, GetUniqueCountsErrors, UploadStaticBundleData, UploadStaticBundleResponses, UploadStaticBundleErrors, ListProjectScansData, ListProjectScansResponses, ListProjectScansErrors, TriggerScanData, TriggerScanResponses, TriggerScanErrors, GetLatestScansPerEnvironmentData, GetLatestScansPerEnvironmentResponses, GetLatestScansPerEnvironmentErrors, GetLatestScanData, GetLatestScanResponses, GetLatestScanErrors, ListWebhooksData, ListWebhooksResponses, ListWebhooksErrors, CreateWebhookData, CreateWebhookResponses, CreateWebhookErrors, DeleteWebhookData, DeleteWebhookResponses, DeleteWebhookErrors, GetWebhookData, GetWebhookResponses, GetWebhookErrors, UpdateWebhookData, UpdateWebhookResponses, UpdateWebhookErrors, ListDeliveriesData, ListDeliveriesResponses, ListDeliveriesErrors, GetDeliveryData, GetDeliveryResponses, GetDeliveryErrors, RetryDeliveryData, RetryDeliveryResponses, RetryDeliveryErrors, WorkflowDryRunData, WorkflowDryRunResponses, WorkflowDryRunErrors, ListMemoryData, ListMemoryResponses, ListMemoryErrors, WriteMemoryData, WriteMemoryResponses, WriteMemoryErrors, SearchMemoryData, SearchMemoryResponses, SearchMemoryErrors, DropMemoryData, DropMemoryResponses, DropMemoryErrors, SupersedeMemoryData, SupersedeMemoryResponses, SupersedeMemoryErrors, WorkspaceListSessionsData, WorkspaceListSessionsResponses, WorkspaceListSessionsErrors, WorkspaceStartSessionData, WorkspaceStartSessionResponses, WorkspaceStartSessionErrors, WorkspaceDeleteSessionData, WorkspaceDeleteSessionResponses, WorkspaceDeleteSessionErrors, WorkspaceGetSessionData, WorkspaceGetSessionResponses, WorkspaceGetSessionErrors, WorkspaceUpdateSessionData, WorkspaceUpdateSessionResponses, WorkspaceUpdateSessionErrors, WorkspaceCancelRunData, WorkspaceCancelRunResponses, WorkspaceCancelRunErrors, WorkspaceCloseSessionData, WorkspaceCloseSessionResponses, WorkspaceCloseSessionErrors, WorkspaceSendMessageData, WorkspaceSendMessageResponses, WorkspaceSendMessageErrors, WorkspaceRegeneratePreviewPasswordData, WorkspaceRegeneratePreviewPasswordResponses, WorkspaceRegeneratePreviewPasswordErrors, WorkspaceReopenSessionData, WorkspaceReopenSessionResponses, WorkspaceReopenSessionErrors, WorkspaceRefreshSandboxData, WorkspaceRefreshSandboxResponses, WorkspaceRefreshSandboxErrors, WorkspaceRestartSandboxData, WorkspaceRestartSandboxResponses, WorkspaceRestartSandboxErrors, WorkspaceStartSandboxData, WorkspaceStartSandboxResponses, WorkspaceStartSandboxErrors, WorkspaceSandboxStatsData, WorkspaceSandboxStatsResponses, WorkspaceSandboxStatsErrors, WorkspaceStopSandboxData, WorkspaceStopSandboxResponses, WorkspaceStopSandboxErrors, WorkspaceStreamMessagesData, WorkspaceStreamMessagesResponses, WorkspaceStreamMessagesErrors, WorkspaceTerminalPasteImageData, WorkspaceTerminalPasteImageResponses, WorkspaceTerminalPasteImageErrors, WorkspaceListTerminalTabsData, WorkspaceListTerminalTabsResponses, WorkspaceListTerminalTabsErrors, WorkspaceDeleteTerminalTabData, WorkspaceDeleteTerminalTabResponses, WorkspaceDeleteTerminalTabErrors, GetProxyLogsData, GetProxyLogsResponses, GetProxyLogsErrors, GetProxyLogByRequestIdData, GetProxyLogByRequestIdResponses, GetProxyLogByRequestIdErrors, GetProjectsHealthData, GetProjectsHealthResponses, GetProjectsHealthErrors, GetTimeBucketStatsData, GetTimeBucketStatsResponses, GetTimeBucketStatsErrors, GetTodayStatsData, GetTodayStatsResponses, GetTodayStatsErrors, GetProxyLogByIdData, GetProxyLogByIdResponses, GetProxyLogByIdErrors, ListSyncedRepositoriesData, ListSyncedRepositoriesResponses, ListSyncedRepositoriesErrors, GetRepositoryByNameData, GetRepositoryByNameResponses, GetRepositoryByNameErrors, GetAllRepositoriesByNameData, GetAllRepositoriesByNameResponses, GetAllRepositoriesByNameErrors, GetRepositoryPresetByNameData, GetRepositoryPresetByNameResponses, GetRepositoryPresetByNameErrors, GetRepositoryBranchesData, GetRepositoryBranchesResponses, GetRepositoryBranchesErrors, GetRepositoryTagsData, GetRepositoryTagsResponses, GetRepositoryTagsErrors, GetRepositoryPresetLiveData, GetRepositoryPresetLiveResponses, GetRepositoryPresetLiveErrors, GetRepositoryByIdData, GetRepositoryByIdResponses, GetRepositoryByIdErrors, GetBranchesByRepositoryIdData, GetBranchesByRepositoryIdResponses, GetBranchesByRepositoryIdErrors, ListCommitsByRepositoryIdData, ListCommitsByRepositoryIdResponses, ListCommitsByRepositoryIdErrors, CheckCommitExistsData, CheckCommitExistsResponses, CheckCommitExistsErrors, GetTagsByRepositoryIdData, GetTagsByRepositoryIdResponses, GetTagsByRepositoryIdErrors, GetRestoreRunData, GetRestoreRunResponses, GetRestoreRunErrors, RevenueGlobalEventsData, RevenueGlobalEventsResponses, RevenueMetricsGlobalMrrData, RevenueMetricsGlobalMrrResponses, RevenueMetricsGlobalSummaryData, RevenueMetricsGlobalSummaryResponses, RevenueListProvidersData, RevenueListProvidersResponses, GetProjectSessionReplaysData, GetProjectSessionReplaysResponses, GetProjectSessionReplaysErrors, GetSessionEventsData, GetSessionEventsResponses, GetSessionEventsErrors, GetSettingsData, GetSettingsResponses, GetSettingsErrors, UpdateSettingsData, UpdateSettingsResponses, UpdateSettingsErrors, SaveAgentTokenData, SaveAgentTokenResponses, SaveAgentTokenErrors, ListAiProvidersData, ListAiProvidersResponses, ListAiProvidersErrors, UpdateAiProviderData, UpdateAiProviderResponses, UpdateAiProviderErrors, ActivateAiProviderData, ActivateAiProviderResponses, ActivateAiProviderErrors, SaveAiProviderCredentialData, SaveAiProviderCredentialResponses, SaveAiProviderCredentialErrors, RevokeJoinTokenData, RevokeJoinTokenResponses, RevokeJoinTokenErrors, GenerateJoinTokenData, GenerateJoinTokenResponses, GenerateJoinTokenErrors, GetJoinTokenStatusData, GetJoinTokenStatusResponses, GetJoinTokenStatusErrors, ListGlobalMcpsData, ListGlobalMcpsResponses, ListGlobalMcpsErrors, CreateGlobalMcpData, CreateGlobalMcpResponses, CreateGlobalMcpErrors, DeleteGlobalMcpData, DeleteGlobalMcpResponses, DeleteGlobalMcpErrors, GetGlobalMcpData, GetGlobalMcpResponses, GetGlobalMcpErrors, UpdateGlobalMcpData, UpdateGlobalMcpResponses, UpdateGlobalMcpErrors, RefreshRouteTableData, RefreshRouteTableResponses, RefreshRouteTableErrors, RebuildSandboxImageData, RebuildSandboxImageResponses, RebuildSandboxImageErrors, GetGlobalSandboxStatusData, GetGlobalSandboxStatusResponses, GetGlobalSandboxStatusErrors, ListSecretsData, ListSecretsResponses, ListSecretsErrors, UpsertSecretData, UpsertSecretResponses, UpsertSecretErrors, DeleteSecretData, DeleteSecretResponses, DeleteSecretErrors, ListGlobalSkillsData, ListGlobalSkillsResponses, ListGlobalSkillsErrors, CreateGlobalSkillData, CreateGlobalSkillResponses, CreateGlobalSkillErrors, UploadGlobalSkillData, UploadGlobalSkillResponses, UploadGlobalSkillErrors, DeleteGlobalSkillData, DeleteGlobalSkillResponses, DeleteGlobalSkillErrors, GetGlobalSkillData, GetGlobalSkillResponses, GetGlobalSkillErrors, UpdateGlobalSkillData, UpdateGlobalSkillResponses, UpdateGlobalSkillErrors, DownloadGlobalSkillArchiveData, DownloadGlobalSkillArchiveResponses, DownloadGlobalSkillArchiveErrors, ListProjectTemplatesData, ListProjectTemplatesResponses, ListProjectTemplatesErrors, ListProjectTemplateTagsData, ListProjectTemplateTagsResponses, ListProjectTemplateTagsErrors, GetProjectTemplateData, GetProjectTemplateResponses, GetProjectTemplateErrors, GetCurrentUserData, GetCurrentUserResponses, GetCurrentUserErrors, ListUsersData, ListUsersResponses, ListUsersErrors, CreateUserData, CreateUserResponses, CreateUserErrors, UpdateSelfData, UpdateSelfResponses, UpdateSelfErrors, DisableMfaData, DisableMfaResponses, DisableMfaErrors, SetupMfaData, SetupMfaResponses, SetupMfaErrors, VerifyAndEnableMfaData, VerifyAndEnableMfaResponses, VerifyAndEnableMfaErrors, DeleteUserData, DeleteUserResponses, DeleteUserErrors, UpdateUserData, UpdateUserResponses, UpdateUserErrors, RestoreUserData, RestoreUserResponses, RestoreUserErrors, AssignRoleData, AssignRoleResponses, AssignRoleErrors, RemoveRoleData, RemoveRoleResponses, RemoveRoleErrors, ListSandboxesData, ListSandboxesResponses, CreateSandboxData, CreateSandboxResponses, CreateSandboxErrors, GetSandboxData, GetSandboxResponses, GetSandboxErrors, CmdData, CmdResponses, CmdErrors, GetCmdData, GetCmdResponses, GetCmdErrors, CmdLogsData, CmdLogsResponses, CmdLogsErrors, DestroySandboxData, DestroySandboxResponses, DestroySandboxErrors, DomainData, DomainResponses, DomainErrors, ExecData, ExecResponses, ExecErrors, ExecDetachedData, ExecDetachedResponses, ExecDetachedErrors, ExtendTimeoutData, ExtendTimeoutResponses, ExtendTimeoutErrors, MkdirData, MkdirResponses, MkdirErrors, ReadFileData, ReadFileResponses, ReadFileErrors, StatPathData, StatPathResponses, StatPathErrors, WriteFileData, WriteFileResponses, WriteFileErrors, WriteFilesData, WriteFilesResponses, WriteFilesErrors, ListJobsData, ListJobsResponses, ListJobsErrors, JobStatusData, JobStatusResponses, JobStatusErrors, KillJobData, KillJobResponses, KillJobErrors, JobLogsData, JobLogsResponses, JobLogsErrors, PauseSandboxData, PauseSandboxResponses, PauseSandboxErrors, ClearPreviewPasswordData, ClearPreviewPasswordResponses, ClearPreviewPasswordErrors, SetPreviewPasswordData, SetPreviewPasswordResponses, SetPreviewPasswordErrors, RestartSandboxData, RestartSandboxResponses, RestartSandboxErrors, ResumeSandboxData, ResumeSandboxResponses, ResumeSandboxErrors, SourceSandboxData, SourceSandboxResponses, SourceSandboxErrors, StopSandboxData, StopSandboxResponses, StopSandboxErrors, CmdKillData, CmdKillResponses, CmdKillErrors, GetVisitorSessionsData, GetVisitorSessionsResponses, GetVisitorSessionsErrors, DeleteSessionReplayData, DeleteSessionReplayResponses, DeleteSessionReplayErrors, GetSessionReplayData, GetSessionReplayResponses, GetSessionReplayErrors, UpdateSessionDurationData, UpdateSessionDurationResponses, UpdateSessionDurationErrors, GetSessionReplayEventsData, GetSessionReplayEventsResponses, GetSessionReplayEventsErrors, AddEventsData, AddEventsResponses, AddEventsErrors, DeleteScanData, DeleteScanResponses, DeleteScanErrors, GetScanData, GetScanResponses, GetScanErrors, GetScanVulnerabilitiesData, GetScanVulnerabilitiesResponses, GetScanVulnerabilitiesErrors, ListEventTypesData, ListEventTypesResponses, TriggerWeeklyDigestData, TriggerWeeklyDigestResponses, TriggerWeeklyDigestErrors, ListExternalPluginsData, ListExternalPluginsResponses, ReloadPluginsData, ReloadPluginsResponses, ReloadPluginsErrors, IngestSentryEnvelopeData, IngestSentryEnvelopeResponses, IngestSentryEnvelopeErrors, IngestSentryEventData, IngestSentryEventResponses, IngestSentryEventErrors, ListAuditLogsData, ListAuditLogsResponses, ListAuditLogsErrors, GetAuditLogData, GetAuditLogResponses, GetAuditLogErrors } from './types.gen'; import { client } from './client.gen'; export type Options = ClientOptions & { @@ -945,6 +945,30 @@ export const deactivateApiKey = (options: }); }; +export const cliLogin = (options: Options) => { + return (options.client ?? client).post({ + url: '/auth/cli/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const cliLogout = (options?: Options) => { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/auth/cli/logout', + ...options + }); +}; + export const emailStatus = (options?: Options) => { return (options?.client ?? client).get({ url: '/auth/email-status', @@ -7170,6 +7194,25 @@ export const updateGitSettings = (options: }); }; +/** + * Reinstall the GitLab webhook for a project + * Removes the existing webhook (if any) and installs a fresh one. + * Use this when a webhook has been manually deleted on the GitLab side + * and automatic deployments have stopped working. + */ +export const reinstallGitlabWebhook = (options: Options) => { + return (options.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/projects/{project_id}/gitlab/reinstall-webhook', + ...options + }); +}; + /** * Check if project has any error groups */ diff --git a/web/src/api/client/types.gen.ts b/web/src/api/client/types.gen.ts index cdbc493d3..3f7c31ae9 100644 --- a/web/src/api/client/types.gen.ts +++ b/web/src/api/client/types.gen.ts @@ -1071,11 +1071,48 @@ export type ChatMessage = { tool_calls?: Array | null; }; +export type CliLoginPasswordRequest = { + /** + * Friendly device name shown to the user when they audit API keys. + * Defaults to `cli` when omitted. + */ + device_name?: string | null; + /** + * User email. + */ + email: string; + /** + * Six-digit TOTP code. Required only when the user has MFA enabled. + */ + mfa_code?: string | null; + /** + * Opaque session token returned by a prior `/auth/cli/login` call when + * MFA was required. Must be passed back together with `mfa_code`. + */ + mfa_session_token?: string | null; + /** + * User password. + */ + password: string; +}; + export type CliLoginRequest = { password: string; username: string; }; +export type CliLoginResponse = { + mfa_required: boolean; + mfa_session_token: string; +} | { + api_key: string; + email: string; + expires_at?: string | null; + key_prefix: string; + role: string; + user_id: number; +}; + /** * Response body for `GET /external-services/{id}/cluster-health`. */ @@ -1361,6 +1398,10 @@ export type ContainerDetailResponse = { * Port inside the container */ container_port: number; + /** + * CPU limit in whole cores (e.g. 1.0). None when no limit is configured. + */ + cpu_limit_cores?: number | null; created_at: string; deployed_at: string; deployment_id: number; @@ -1368,12 +1409,32 @@ export type ContainerDetailResponse = { * Environment variables (sensitive values masked) */ environment_variables: Array; + /** + * Free-form error string from Docker's container state on exit. + */ + error_message?: string | null; + /** + * Process exit code reported by Docker. None while still running. + */ + exit_code?: number | null; + /** + * Human-readable reason the container exited. + */ + exit_reason?: string | null; + /** + * When the container exited (Docker's FinishedAt). None while running. + */ + finished_at?: string | null; /** * Port on the host machine */ host_port?: number | null; id: number; image_name: string; + /** + * True when Docker's OOM killer terminated the container. + */ + oom_killed?: boolean | null; ready_at?: string | null; resource_limits?: null | ResourceLimitsResponse; /** @@ -1388,18 +1449,52 @@ export type ContainerDetailResponse = { * Per-service URL for compose deployments */ service_url?: string | null; + /** + * When the container's main process most recently started. + */ + started_at?: string | null; status: string; }; export type ContainerInfoResponse = { container_id: string; container_name: string; + /** + * CPU limit in whole cores (e.g. 1.0). None when no limit is configured. + */ + cpu_limit_cores?: number | null; created_at: string; + /** + * Free-form error string from Docker's container state on exit. + */ + error_message?: string | null; + /** + * Process exit code reported by Docker. None while still running. + */ + exit_code?: number | null; + /** + * Human-readable reason the container exited (e.g. "OOMKilled", + * "Killed by SIGKILL (exit code 137)", "Exit code 1"). None while running. + */ + exit_reason?: string | null; + /** + * When the container exited (Docker's FinishedAt). None while running. + */ + finished_at?: string | null; image_name: string; /** * Node name where this container is running. None for local (single-node) deployments. */ node_name?: string | null; + /** + * True when Docker's OOM killer terminated the container. + */ + oom_killed?: boolean | null; + /** + * Container restart count from Docker. The UI shows a chip when this is + * > 0 so a crash loop is visible without opening detail. + */ + restart_count?: number | null; /** * Compose service name (e.g. "web", "redis"). None for single-container deployments. */ @@ -1408,6 +1503,12 @@ export type ContainerInfoResponse = { * Per-service URL for compose deployments (e.g. "https://web-myapp.localho.st") */ service_url?: string | null; + /** + * When the container's main process most recently started. The UI uses + * this for the uptime label so the count resets when a container is + * restarted in place. None for containers that never started. + */ + started_at?: string | null; status: string; }; @@ -8394,6 +8495,12 @@ export type ProjectResponse = { * Git clone URL for the repository (used for public repos without a provider connection) */ git_url?: string | null; + /** + * GitLab webhook ID installed on the connected repository. + * `null` when no GitLab webhook is installed (not connected to GitLab, + * or webhook was removed / never created). + */ + gitlab_webhook_id?: number | null; id: number; last_deployment?: number | null; main_branch: string; @@ -9197,6 +9304,20 @@ export type RegisterRequest = { password: string; }; +/** + * Response for `POST /projects/{project_id}/gitlab/reinstall-webhook` + */ +export type ReinstallWebhookResponse = { + /** + * The new GitLab hook ID that was installed. + */ + hook_id: number; + /** + * Human-readable status message. + */ + message: string; +}; + export type ReleaseListResponse = { releases: Array; }; @@ -15948,6 +16069,68 @@ export type DeactivateApiKeyResponses = { export type DeactivateApiKeyResponse = DeactivateApiKeyResponses[keyof DeactivateApiKeyResponses]; +export type CliLoginData = { + body: CliLoginPasswordRequest; + path?: never; + query?: never; + url: '/auth/cli/login'; +}; + +export type CliLoginErrors = { + /** + * Invalid credentials or MFA code + */ + 401: unknown; + /** + * User has no role assigned + */ + 403: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type CliLoginResponses = { + /** + * Login successful or MFA challenge issued + */ + 200: CliLoginResponse; +}; + +export type CliLoginResponse2 = CliLoginResponses[keyof CliLoginResponses]; + +export type CliLogoutData = { + body?: never; + path?: never; + query?: never; + url: '/auth/cli/logout'; +}; + +export type CliLogoutErrors = { + /** + * Not authenticated + */ + 401: unknown; + /** + * Endpoint requires API key authentication + */ + 403: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type CliLogoutResponses = { + /** + * API key revoked + */ + 204: void; +}; + +export type CliLogoutResponse = CliLogoutResponses[keyof CliLogoutResponses]; + export type EmailStatusData = { body?: never; path?: never; @@ -30856,6 +31039,50 @@ export type UpdateGitSettingsResponses = { export type UpdateGitSettingsResponse = UpdateGitSettingsResponses[keyof UpdateGitSettingsResponses]; +export type ReinstallGitlabWebhookData = { + body?: never; + path: { + /** + * Project ID + */ + project_id: number; + }; + query?: never; + url: '/projects/{project_id}/gitlab/reinstall-webhook'; +}; + +export type ReinstallGitlabWebhookErrors = { + /** + * Project is not connected to a GitLab repository + */ + 400: unknown; + /** + * Unauthorized + */ + 401: unknown; + /** + * Forbidden + */ + 403: unknown; + /** + * Project not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ReinstallGitlabWebhookResponses = { + /** + * Webhook reinstalled + */ + 200: ReinstallWebhookResponse; +}; + +export type ReinstallGitlabWebhookResponse = ReinstallGitlabWebhookResponses[keyof ReinstallGitlabWebhookResponses]; + export type HasErrorGroupsData = { body?: never; path: { diff --git a/web/src/components/command/CommandPalette.tsx b/web/src/components/command/CommandPalette.tsx index 287e031cd..5b8c5ef9e 100644 --- a/web/src/components/command/CommandPalette.tsx +++ b/web/src/components/command/CommandPalette.tsx @@ -361,12 +361,6 @@ const projectNavItems: NavigationItem[] = [ icon: BarChart3, keywords: ['funnels', 'conversion', 'flow', 'analytics'], }, - { - title: 'Request Logs', - url: 'analytics/requests', - icon: ScrollText, - keywords: ['logs', 'requests', 'http', 'analytics'], - }, { title: 'Analytics Setup', url: 'analytics/setup', @@ -380,7 +374,7 @@ const projectNavItems: NavigationItem[] = [ keywords: ['database', 'storage', 'data'], }, { - title: 'Runtime Logs', + title: 'Logs', url: 'runtime', icon: ScrollText, keywords: ['logs', 'runtime', 'console', 'output', 'live'], @@ -973,7 +967,7 @@ export function CommandPalette() { onOpenChange={setOpen} contentClassName="sm:max-w-2xl" > - + @@ -18,12 +41,20 @@ export function ContainerConfiguration({ description="Identity and runtime metadata for this container." > - + {container.service_name && ( @@ -38,6 +69,86 @@ export function ContainerConfiguration({ + {hasResourceLimits && ( +
+ + {cpuRequestMilli != null && ( + + )} + {cpuLimitMilli != null && ( + + )} + {memoryRequestMb != null && ( + + )} + {memoryLimitMb != null && ( + + )} + {cpuLimitObservedCores != null && ( + + )} + +
+ )} + + {isExited && container.exit_reason && ( +
+ + + {container.exit_code != null && ( + + )} + {container.finished_at && ( + + )} + {container.error_message && ( + + )} + +
+ )} + {hasPorts && (
= 1) { + const rounded = Math.round(cores * 100) / 100 + return `${rounded} ${rounded === 1 ? 'core' : 'cores'}` + } + return `${Math.round(cores * 1000)}m` +} + +function formatCoresFromMilli(milli: number): string { + return formatCores(milli / 1000) +} + +function formatMemoryMb(mb: number): string { + if (mb >= 1024) { + const gb = mb / 1024 + return `${gb % 1 === 0 ? gb.toFixed(0) : gb.toFixed(2)} GB` + } + return `${Math.round(mb)} MB` +} diff --git a/web/src/components/containers/ContainerHeaderBar.tsx b/web/src/components/containers/ContainerHeaderBar.tsx index cb516511d..5e4159509 100644 --- a/web/src/components/containers/ContainerHeaderBar.tsx +++ b/web/src/components/containers/ContainerHeaderBar.tsx @@ -119,22 +119,38 @@ export function ContainerHeaderBar({ {selectedContainer.node_name} )} - {selectedContainer?.created_at && ( - + {(metrics?.started_at || selectedContainer?.created_at) && ( + + )} + {(metrics?.restart_count ?? 0) > 0 && ( + )} {isRunning && metrics && ( <>
@@ -265,7 +281,8 @@ function ContainerSwitcher({ onSelect, }: ContainerSwitcherProps) { const canSwitch = allCount > 1 - const label = selected?.service_name || selected?.container_name || 'Select container' + const label = + selected?.service_name || selected?.container_name || 'Select container' if (!canSwitch) { return ( @@ -397,13 +414,19 @@ function StatusPill({ status }: { status?: ContainerStatus }) { ) } -function UptimeInline({ createdAt }: { createdAt: string }) { - const [label, setLabel] = useState(() => formatUptime(createdAt)) +function UptimeInline({ startedAt }: { startedAt: string | null }) { + // The label is a pure derivation of `startedAt` + wall clock. We only need + // an interval to *recompute* — no need to write derived state in an effect. + const [tick, setTick] = useState(0) useEffect(() => { - setLabel(formatUptime(createdAt)) - const id = setInterval(() => setLabel(formatUptime(createdAt)), 30_000) + const id = setInterval(() => setTick((t) => t + 1), 30_000) return () => clearInterval(id) - }, [createdAt]) + }, []) + // `tick` is intentional — it forces the label to recompute against the + // current wall clock every 30s without persisting derived state. + // eslint-disable-next-line react-hooks/exhaustive-deps + const label = useMemo(() => formatUptime(startedAt), [startedAt, tick]) + if (!label) return null return (
{label} uptime @@ -411,8 +434,12 @@ function UptimeInline({ createdAt }: { createdAt: string }) { ) } -function formatUptime(createdAt: string): string { - const elapsedMs = Date.now() - new Date(createdAt).getTime() +function formatUptime(startedAt: string | null): string { + if (!startedAt) return '' + const t = new Date(startedAt).getTime() + if (!Number.isFinite(t)) return '' + const elapsedMs = Date.now() - t + if (elapsedMs < 0) return '' const s = Math.max(0, Math.floor(elapsedMs / 1000)) if (s < 60) return `${s}s` const m = Math.floor(s / 60) @@ -423,6 +450,43 @@ function formatUptime(createdAt: string): string { return `${d}d ${h % 24}h` } +function RestartCountChip({ count }: { count: number }) { + // 1 restart is noteworthy, > 3 is alarming. Tone the chip accordingly so + // a crash loop is visible from a distance without making a single planned + // restart look like an emergency. + const tone = + count >= 3 + ? 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/30' + : 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-400 dark:ring-amber-500/30' + return ( + + + ) +} + +/** Docker reports the host's total memory as the "limit" when no explicit + * limit was set on the container. Treat anything above 100 GiB as no-limit + * to avoid showing nonsense numbers like "Mem 126 MB / 128 GB". */ +function hasRealMemoryLimit(bytes: number | null | undefined): boolean { + if (bytes == null || bytes <= 0) return false + const HUNDRED_GIB = 100 * 1024 * 1024 * 1024 + return bytes < HUNDRED_GIB +} + +function formatCores(cores: number): string { + if (cores >= 1) { + const rounded = Math.round(cores * 100) / 100 + return `${rounded} ${rounded === 1 ? 'core' : 'cores'}` + } + // Sub-core: render in millicores so "0.5" doesn't look ambiguous. + return `${Math.round(cores * 1000)}m` +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes.toFixed(0)} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` diff --git a/web/src/components/containers/useContainerMetricsStream.ts b/web/src/components/containers/useContainerMetricsStream.ts index 2d342f68d..a8c7b0b2c 100644 --- a/web/src/components/containers/useContainerMetricsStream.ts +++ b/web/src/components/containers/useContainerMetricsStream.ts @@ -2,23 +2,37 @@ import { useEffect, useState } from 'react' export interface LiveMetrics { cpu_percent: number + /** CPU limit in whole cores (e.g. 1.0). null = no limit. */ + cpu_limit_cores: number | null memory_bytes: number - memory_limit_bytes: number - memory_percent: number + /** Bytes; 0/null when no limit. Docker reports the host's total RAM as the + * "limit" when no explicit limit is set, so callers should treat very large + * values (e.g. > 100 GiB) as "no limit set". */ + memory_limit_bytes: number | null + memory_percent: number | null network_rx_bytes: number network_tx_bytes: number network_rx_rate: number network_tx_rate: number + /** Restart count from Docker. > 0 means the container was restarted at + * least once since being created. */ + restart_count: number | null + /** When the current process most recently started (RFC3339). Use this for + * uptime — the container could have been restarted in place after a crash. */ + started_at: string | null timestamp: string } interface RawMetric { cpu_percent: number + cpu_limit_cores?: number | null memory_bytes: number - memory_limit_bytes: number - memory_percent: number + memory_limit_bytes?: number | null + memory_percent?: number | null network_rx_bytes: number network_tx_bytes: number + restart_count?: number | null + started_at?: string | null timestamp: string } @@ -58,12 +72,19 @@ export function useContainerMetricsStream( 1000, 1 ) - rxRate = Math.max(next.network_rx_bytes - prev.network_rx_bytes, 0) / dt - txRate = Math.max(next.network_tx_bytes - prev.network_tx_bytes, 0) / dt + rxRate = + Math.max(next.network_rx_bytes - prev.network_rx_bytes, 0) / dt + txRate = + Math.max(next.network_tx_bytes - prev.network_tx_bytes, 0) / dt } prev = next setMetrics({ ...next, + cpu_limit_cores: next.cpu_limit_cores ?? null, + memory_limit_bytes: next.memory_limit_bytes ?? null, + memory_percent: next.memory_percent ?? null, + restart_count: next.restart_count ?? null, + started_at: next.started_at ?? null, network_rx_rate: rxRate, network_tx_rate: txRate, }) diff --git a/web/src/components/dashboard/Sidebar.tsx b/web/src/components/dashboard/Sidebar.tsx index 5f917d71a..1ae8e81b9 100644 --- a/web/src/components/dashboard/Sidebar.tsx +++ b/web/src/components/dashboard/Sidebar.tsx @@ -32,6 +32,7 @@ import { Folder, Gauge, GitBranch, + GitFork, Globe, HardDrive, Home, @@ -54,9 +55,11 @@ import { ShieldAlert, SlidersHorizontal, Sparkles, + TerminalSquare, Users, Wand2, Webhook, + Workflow, Zap, } from 'lucide-react' @@ -356,7 +359,13 @@ export default function AppSidebar() { {showDefault ? ( - + setForceDefault(false)} + /> ) : settingsMode ? ( setForceDefault(true)} /> ) : projectSlug ? ( @@ -541,9 +550,19 @@ function NavUser() { interface NavProps { pluginItems: { title: string; url: string; icon: LucideIcon }[] + // Slug of the project the user is currently viewing (URL still + // points inside `/projects/:slug/...`) but has temporarily swapped + // the sidebar to default via Back. When set, render a pinned row at + // the top so they can return to the project sidebar in one click. + pinnedProjectSlug?: string | null + onReturnToProject?: () => void } -function DefaultNav({ pluginItems }: NavProps) { +function DefaultNav({ + pluginItems, + pinnedProjectSlug, + onReturnToProject, +}: NavProps) { const { isMinimal, isMobile } = useSidebar() const compact = isMinimal && !isMobile @@ -557,6 +576,12 @@ function DefaultNav({ pluginItems }: NavProps) { return ( <> + {pinnedProjectSlug && onReturnToProject && ( + + )} {grouped.map((group) => ( void +}) { + const { isMinimal, isMobile } = useSidebar() + const compact = isMinimal && !isMobile + const { data: project } = useQuery({ + ...getProjectBySlugOptions({ path: { slug } }), + }) + const label = project?.name ?? slug + if (compact) { + return ( + + + + + + + + + + ) + } + return ( + + + + ) +} + // Shared back-arrow header used by Settings, Project, and drill-down // sub-views. `onBack` is a state callback — it never navigates. function SwapHeader({ diff --git a/web/src/components/deployments/BranchSelector.tsx b/web/src/components/deployments/BranchSelector.tsx index ebb8beec7..247640b90 100644 --- a/web/src/components/deployments/BranchSelector.tsx +++ b/web/src/components/deployments/BranchSelector.tsx @@ -170,6 +170,19 @@ export function BranchSelector({ const effectiveBranch = value !== undefined ? value : (defaultBranch ?? '') + // Always include the currently-selected branch in the dropdown, even when + // the API didn't return it (huge branch counts hitting the safety cap, a + // branch that was renamed/deleted upstream, or an unauthenticated public + // fetch that excluded it). Without this, Radix Select falls back to the + // placeholder and the user sees an empty trigger. + const displayBranches = useMemo(() => { + if (!effectiveBranch) return sortedBranches + if (sortedBranches.some((b) => b.name === effectiveBranch)) { + return sortedBranches + } + return [{ name: effectiveBranch }, ...sortedBranches] + }, [sortedBranches, effectiveBranch]) + // Notify parent when branches are loaded useEffect(() => { if (sortedBranches.length > 0 && onBranchesLoaded) { @@ -188,7 +201,7 @@ export function BranchSelector({ Your Git provider token has expired. Please reconnect to continue.

- @@ -216,7 +229,10 @@ export function BranchSelector({ ) } - if (sortedBranches.length > 0 && !isCustomBranch) { + if (displayBranches.length > 0 && !isCustomBranch) { + const selectedNotInList = + !!effectiveBranch && + !sortedBranches.some((b) => b.name === effectiveBranch) return (
- Minimum CPU resources (1000m = 1 CPU core) + Minimum CPU cores (e.g., 0.25, 0.5, 1, 2) )} @@ -323,17 +330,18 @@ export function GeneralSettings({ project, refetch }: GeneralSettingsProps) { name="cpuLimit" render={({ field }) => ( - CPU Limit (millicores) + CPU Limit (cores) - Maximum CPU resources (1000m = 1 CPU core) + Maximum CPU cores (e.g., 0.5, 1, 2) )} diff --git a/web/src/components/project/settings/GitSettings.tsx b/web/src/components/project/settings/GitSettings.tsx index 28012b67a..a017f1bab 100644 --- a/web/src/components/project/settings/GitSettings.tsx +++ b/web/src/components/project/settings/GitSettings.tsx @@ -9,30 +9,20 @@ import { getRepositoryPresetLiveOptions, listConnectionsOptions, listGitProvidersOptions, + reinstallGitlabWebhookMutation, updateAutomaticDeployMutation, updateGitSettingsMutation, } from '@/api/client/@tanstack/react-query.gen' import { RepositorySelector } from '@/components/repositories/RepositorySelector' -import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from '@/components/ui/card' -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -44,11 +34,9 @@ import { } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import GithubIcon from '@/icons/Github' -import { zodResolver } from '@hookform/resolvers/zod' -import { useFieldArray } from 'react-hook-form' +import { cn } from '@/lib/utils' import { useMutation, useQuery } from '@tanstack/react-query' import { - AlertCircle, Check, FileIcon, FolderIcon, @@ -59,10 +47,8 @@ import { Trash2, } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { useForm, useWatch } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' -import { z } from 'zod' import FrameworkIcon from '../FrameworkIcon' import { TimeAgo } from '@/components/utils/TimeAgo' import { FrameworkSelector } from '../FrameworkSelector' @@ -72,337 +58,32 @@ interface GitSettingsProps { refetch: () => void } -// Unified schema for all git settings -const gitSettingsSchema = z.object({ - branch: z.string(), - preset: z.string().optional(), - directory: z.string().optional(), - dockerfilePath: z.string().optional(), - composePath: z.string().optional(), - composeOverride: z.string().optional(), - publicPorts: z - .array( - z.object({ - service: z.string(), - port: z.number().min(1).max(65535), - }) - ) - .optional(), -}) - -type GitSettingsFormValues = z.infer - -/** Known service/port combinations commonly found in Docker Compose files */ -const COMMON_PORTS: Record = { - clickhouse: [8123, 9000], - postgres: [5432], - mysql: [3306], - redis: [6379], - mongodb: [27017], - elasticsearch: [9200, 9300], - rabbitmq: [5672, 15672], - kafka: [9092], - nginx: [80, 443], - traefik: [80, 443, 8080], - minio: [9000, 9001], - grafana: [3000], - prometheus: [9090], -} - -function PortSuggestions({ - suggestions, - currentPorts, - onAdd, -}: { - suggestions: { service: string; port: number }[] - currentPorts: { service: string; port: number }[] - onAdd: (s: { service: string; port: number }) => void -}) { - const remaining = suggestions.filter( - (s) => - !currentPorts.some( - (cp) => cp.service === s.service && cp.port === s.port - ) - ) - if (remaining.length === 0) return null - return ( -
-

Detected from compose:

-
- {remaining.map((s) => ( - - ))} -
-
- ) -} - -function PublicPortsField({ - form, -}: { - form: ReturnType> -}) { - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: 'publicPorts', - }) - // Parse compose override to suggest services/ports - const composeOverride = useWatch({ - control: form.control, - name: 'composeOverride', - }) - - // Suggest services from compose override or common ports - const suggestions = useMemo(() => { - const result: { service: string; port: number }[] = [] - // Parse services from compose override if available - if (composeOverride) { - let inServices = false - let currentService: string | null = null - let servicesIndent = 0 - let serviceIndent: number | null = null - for (const line of composeOverride.split('\n')) { - const trimmed = line.trim() - const indent = line.length - line.trimStart().length - if (trimmed === 'services:' || trimmed.startsWith('services:')) { - inServices = true - servicesIndent = indent - serviceIndent = null - continue - } - if (inServices && indent <= servicesIndent && trimmed) { - inServices = false - } - if ( - inServices && - trimmed.endsWith(':') && - !trimmed.includes(' ') && - !trimmed.startsWith('-') - ) { - if (serviceIndent === null || indent === serviceIndent) { - serviceIndent = indent - currentService = trimmed.replace(':', '') - // Add common ports for this service - const known = COMMON_PORTS[currentService.toLowerCase()] - if (known) { - known.forEach((p) => - result.push({ service: currentService!, port: p }) - ) - } - } - } - // Parse port entries: handles - 48080:80, - "48080:80", - '48080:80' - // Uses the host port (left side) since that's what's externally accessible - if (currentService && trimmed.startsWith('-')) { - const portMatch = trimmed.match(/(\d+):(\d+)/) - if (portMatch) { - result.push({ - service: currentService!, - port: parseInt(portMatch[1]), - }) - } - } - } - } - return result - }, [composeOverride]) - - return ( -
-
-
- -

- Ports exposed publicly through the proxy. All other ports remain - private. -

-
- -
- - {fields.length === 0 && ( -

- No public ports configured. All services are private by default. -

- )} - - {fields.map((field, index) => ( -
- - - -
- ))} - - ({ - service: p.service || '', - port: p.port || 0, - }))} - onAdd={(s) => append(s)} - /> - - {/* Datalist for autocomplete */} - - {[...new Set(suggestions.map((s) => s.service))].map((svc) => ( - -
- ) -} - -function getGithubRepoUrl(owner: string, repo: string) { - return `https://github.com/${owner}/${repo}` -} - -export function GitSettings({ project, refetch }: GitSettingsProps) { - const navigate = useNavigate() - const updateGithubRepo = useMutation({ +function GitSettingsInline({ project, refetch }: GitSettingsProps) { + const updateGitSettings = useMutation({ ...updateGitSettingsMutation(), - meta: { - errorTitle: 'Failed to update git settings', - }, + meta: { errorTitle: 'Failed to update git settings' }, }) const updateAutomaticDeploy = useMutation({ ...updateAutomaticDeployMutation(), - meta: { - errorTitle: 'Failed to update automatic deploy settings', - }, + meta: { errorTitle: 'Failed to update auto-deploy' }, }) - const [isEditingSettings, setIsEditingSettings] = useState(false) - const [isCustomBranch, setIsCustomBranch] = useState(false) - const [customBranch, setCustomBranch] = useState('') - const [selectedConnectionId, setSelectedConnectionId] = useState< - number | null - >(() => project?.git_provider_connection_id || null) - const [selectedRepository, setSelectedRepository] = - useState(null) - const [isSelectingRepository, setIsSelectingRepository] = useState(false) - const [publicRepoUrl, setPublicRepoUrl] = useState('') - const [parsedPublicRepo, setParsedPublicRepo] = useState<{ owner: string; name: string } | null>(null) - // Unified form for all git settings - const form = useForm({ - resolver: zodResolver(gitSettingsSchema), - defaultValues: { - branch: project?.main_branch || '', - preset: project?.preset || '', - directory: project?.directory || '', - dockerfilePath: - (project?.preset_config as any)?.dockerfilePath || 'Dockerfile', - composePath: - (project?.preset_config as any)?.composePath || 'docker-compose.yml', - composeOverride: - (project?.preset_config as any)?.composeOverride || '', - publicPorts: - (project?.preset_config as any)?.publicPorts || [], - }, - }) - - // Sync form with project values when project changes - useEffect(() => { - if (project) { - form.reset({ - branch: project.main_branch || '', - preset: project.preset || '', - directory: project.directory || '', - dockerfilePath: - (project?.preset_config as any)?.dockerfilePath || 'Dockerfile', - composePath: - (project?.preset_config as any)?.composePath || 'docker-compose.yml', - composeOverride: - (project?.preset_config as any)?.composeOverride || '', - publicPorts: - (project?.preset_config as any)?.publicPorts || [], - }) - } - }, [project, form]) - - // Watch preset changes for directory field behavior - const currentPreset = useWatch({ - control: form.control, - name: 'preset', - }) - - // State to track if user wants to manually override directory - const [allowDirectoryOverride, setAllowDirectoryOverride] = useState(false) - - // Fetch git providers - const { data: providersData, isLoading: isLoadingProviders } = useQuery({ - ...listGitProvidersOptions(), - }) - - const providers = useMemo(() => providersData || [], [providersData]) - const hasProviders = useMemo(() => providers.length > 0, [providers]) + // ---------------- Live API data ---------------- + const isPublicRepo = !project?.git_provider_connection_id - // Fetch connections to get the current connection details - const { data: connectionsData } = useQuery({ - ...listConnectionsOptions(), - }) + const { data: providersData } = useQuery({ ...listGitProvidersOptions() }) + const providers = providersData || [] - // Find the current connection - const currentConnection = useMemo( - () => - connectionsData?.connections?.find( - (conn) => conn.id === project?.git_provider_connection_id - ), - [connectionsData, project] + const { data: connectionsData } = useQuery({ ...listConnectionsOptions() }) + const currentConnection = connectionsData?.connections?.find( + (c) => c.id === project?.git_provider_connection_id, ) - const currentProvider = useMemo( - () => - providers.find( - (provider) => provider.id === currentConnection?.provider_id - ), - [providers, currentConnection?.provider_id] + const currentProvider = providers.find( + (p) => p.id === currentConnection?.provider_id, ) - // Fetch branches from repository - const { - data: branchesData, - isLoading: isLoadingBranches, - refetch: refetchBranches, - } = useQuery({ + const { data: branchesData, isLoading: isLoadingBranches, refetch: refetchBranches } = useQuery({ queryKey: [ 'repository-branches', project?.repo_owner, @@ -410,46 +91,22 @@ export function GitSettings({ project, refetch }: GitSettingsProps) { project?.git_provider_connection_id, ], queryFn: async () => { - if ( - !project?.repo_owner || - !project?.repo_name || - !project?.git_provider_connection_id - ) { - return { branches: [] } - } - try { - const response = await getRepositoryBranches({ - path: { - owner: project.repo_owner, - repo: project.repo_name, - }, - query: { - connection_id: project.git_provider_connection_id, - }, - }) - return response.data || { branches: [] } - } catch (error) { - console.error('Failed to fetch branches:', error) - return { branches: [] } + if (!project?.repo_owner || !project?.repo_name || !project?.git_provider_connection_id) { + return { branches: [] as Array<{ name: string; commit_sha: string; protected: boolean }> } } + const response = await getRepositoryBranches({ + path: { owner: project.repo_owner, repo: project.repo_name }, + query: { connection_id: project.git_provider_connection_id }, + }) + return response.data || { branches: [] } }, enabled: - !!project?.repo_owner && - !!project?.repo_name && - !!project?.git_provider_connection_id, + !!project?.repo_owner && !!project?.repo_name && !!project?.git_provider_connection_id, }) + const branches: Array<{ name: string; commit_sha: string; protected: boolean }> = + (branchesData?.branches as any) || [] - const branches = useMemo(() => branchesData?.branches || [], [branchesData]) - const currentBranch = useWatch({ control: form.control, name: 'branch' }) - - // Derive if the current branch is custom (not in the branches list) - const isCurrentBranchCustom = useMemo(() => { - if (!currentBranch || branches.length === 0) return false - const branchNames = branches.map((b: any) => b.name || b) - return !branchNames.includes(currentBranch) - }, [currentBranch, branches]) - - // Get repository ID for live preset detection + // Repository metadata (description, language, pushed_at, default_branch, clone urls) const { data: repositoryData } = useQuery({ queryKey: [ 'repository-search', @@ -465,39 +122,29 @@ export function GitSettings({ project, refetch }: GitSettingsProps) { ) { return null } - try { - const response = await listRepositoriesByConnection({ - path: { connection_id: project.git_provider_connection_id }, - query: { search: project.repo_name, per_page: 100 }, - throwOnError: true, - }) - - const repo = response.data?.repositories?.find( - (r: any) => - r.owner === project.repo_owner && r.name === project.repo_name - ) - return repo || null - } catch (error) { - console.error('Failed to find repository:', error) - return null - } + const response = await listRepositoriesByConnection({ + path: { connection_id: project.git_provider_connection_id }, + query: { search: project.repo_name, per_page: 100 }, + throwOnError: true, + }) + return ( + response.data?.repositories?.find( + (r: any) => r.owner === project.repo_owner && r.name === project.repo_name, + ) || null + ) }, enabled: - !!project?.repo_owner && - !!project?.repo_name && - !!project?.git_provider_connection_id, + !!project?.repo_owner && !!project?.repo_name && !!project?.git_provider_connection_id, }) - // Get live preset detection for authenticated repos + // Preset detection — authenticated repos const presetQuery = useQuery({ ...getRepositoryPresetLiveOptions({ path: { repository_id: repositoryData?.id || 0 }, }), enabled: !!repositoryData?.id, }) - - // Get preset detection for public repos (no git connection = public) - const isPublicRepo = !project?.git_provider_connection_id + // Preset detection — public repos const publicPresetQuery = useQuery({ ...detectPublicPresetsOptions({ path: { @@ -508,8 +155,6 @@ export function GitSettings({ project, refetch }: GitSettingsProps) { }), enabled: isPublicRepo && !!project?.repo_owner && !!project?.repo_name, }) - - // Transform public preset data to match FrameworkSelector format (camelCase) const publicPresetData = useMemo(() => { if (!publicPresetQuery.data?.presets?.length) return null return { @@ -524,1269 +169,1042 @@ export function GitSettings({ project, refetch }: GitSettingsProps) { })), } }, [publicPresetQuery.data]) - - // Combined preset data: authenticated or public const effectivePresetData = presetQuery.data || publicPresetData - const effectivePresetLoading = presetQuery.isLoading || publicPresetQuery.isLoading || publicPresetQuery.isFetching - - const presets = useMemo(() => { - if (effectivePresetData?.presets && effectivePresetData.presets.length > 0) { - return effectivePresetData.presets.map((preset: any) => ({ - value: preset.preset, - label: preset.presetLabel || preset.preset_label || preset.preset, - directory: preset.path || './', - })) + const effectivePresetLoading = + presetQuery.isLoading || publicPresetQuery.isLoading || publicPresetQuery.isFetching + + // ---------------- Save helpers ---------------- + // The API expects a full update; build it from current project state plus overrides. + const saveGitField = async (overrides: Partial<{ + main_branch: string + preset: string + directory: string + preset_config: any + repo_owner: string + repo_name: string + git_url: string + is_public_repo: boolean + git_provider_connection_id: number | null + }>) => { + const presetCfg: any = (project?.preset_config as any) || {} + const body: Record = { + main_branch: overrides.main_branch ?? project.main_branch, + preset: overrides.preset ?? project.preset, + directory: overrides.directory ?? project.directory ?? './', + repo_owner: overrides.repo_owner ?? project.repo_owner!, + repo_name: overrides.repo_name ?? project.repo_name!, + preset_config: overrides.preset_config ?? presetCfg ?? undefined, + } + if (isPublicRepo) { + body.git_url = + overrides.git_url ?? + project.git_url ?? + `https://github.com/${project.repo_owner}/${project.repo_name}` + body.is_public_repo = true + body.git_provider_connection_id = null + } else { + body.git_provider_connection_id = + overrides.git_provider_connection_id ?? project.git_provider_connection_id ?? null } + await updateGitSettings.mutateAsync({ + body: body as any, + path: { project_id: project.id }, + }) + refetch() + } - // Fallback to all available presets if no detection data - return [ - { value: 'docker-compose', label: 'Docker Compose', directory: './' }, - { value: 'dockerfile', label: 'Dockerfile', directory: './' }, - { value: 'nextjs', label: 'Next.js', directory: './' }, - { value: 'vite', label: 'Vite', directory: './' }, - { value: 'rsbuild', label: 'RSBuild', directory: './' }, - { value: 'astro', label: 'Astro', directory: './' }, - { value: 'nuxt', label: 'Nuxt', directory: './' }, - { value: 'remix', label: 'Remix', directory: './' }, - { value: 'python', label: 'Python', directory: './' }, - { value: 'go', label: 'Go', directory: './' }, - { value: 'rust', label: 'Rust', directory: './' }, - { value: 'nodejs', label: 'Node.js', directory: './' }, - { value: 'static', label: 'Static', directory: './' }, - ] - }, [effectivePresetData]) + // ---------------- Inline editors ---------------- + const [editing, setEditing] = useState(null) + const close = () => setEditing(null) - // Unified handler for all git settings - const handleUpdateSettings = async (values: GitSettingsFormValues) => { - try { - // Extract just the preset name from "preset::path" format for backend - const [presetName] = values.preset?.split('::') || [''] + // Branch editor + const [branchDraft, setBranchDraft] = useState('') + useEffect(() => setBranchDraft(project.main_branch || ''), [project.main_branch]) - // Build preset_config for presets that support it - const isComposePreset = presetName === 'docker-compose' || presetName === 'dockercompose' - const presetConfig = - presetName === 'dockerfile' && values.dockerfilePath - ? { preset: 'dockerfile', dockerfilePath: values.dockerfilePath } - : isComposePreset - ? { - preset: 'docker-compose', - composePath: values.composePath || 'docker-compose.yml', - composeOverride: values.composeOverride || undefined, - publicPorts: values.publicPorts?.length ? values.publicPorts : undefined, - } - : undefined + // Directory editor + const [directoryDraft, setDirectoryDraft] = useState('') + useEffect(() => setDirectoryDraft(project.directory || './'), [project.directory]) - // Backend normalizes "dockercompose" -> "docker-compose" via FromStr - const backendPreset = isComposePreset ? 'docker-compose' : presetName + // Dockerfile path editor + const [dockerfileDraft, setDockerfileDraft] = useState('') + useEffect(() => { + setDockerfileDraft((project?.preset_config as any)?.dockerfilePath || 'Dockerfile') + }, [project?.preset_config]) - const updateBody: Record = { - main_branch: values.branch, - preset: backendPreset, - directory: values.directory!, - repo_owner: project.repo_owner!, - repo_name: project.repo_name!, - preset_config: presetConfig, - } + // Compose path editor + const [composePathDraft, setComposePathDraft] = useState('') + useEffect(() => { + setComposePathDraft((project?.preset_config as any)?.composePath || 'docker-compose.yml') + }, [project?.preset_config]) - if (isPublicRepo) { - updateBody.git_url = project.git_url || `https://github.com/${project.repo_owner}/${project.repo_name}` - updateBody.is_public_repo = true - updateBody.git_provider_connection_id = null - } else { - updateBody.git_provider_connection_id = - selectedConnectionId ?? - project.git_provider_connection_id ?? - null - } + // Compose override editor (full-width textarea, explicit save) + const [overrideDraft, setOverrideDraft] = useState('') + useEffect(() => { + setOverrideDraft((project?.preset_config as any)?.composeOverride || '') + }, [project?.preset_config]) - await updateGithubRepo.mutateAsync({ - body: updateBody as any, - path: { project_id: project.id }, - }) - toast.success('Git settings updated successfully') - setIsEditingSettings(false) - refetch() - } catch (error) { - console.error('Failed to update git settings:', error) - toast.error('Failed to update git settings') - } - } + const presetName = (project.preset || '').toString() + const isDockerfilePreset = presetName === 'dockerfile' + const isComposePreset = presetName === 'docker-compose' || presetName === 'dockercompose' - const handleRepositorySelect = async (repo: RepositoryResponse | null) => { - if (!repo) { - setSelectedRepository(null) - return - } + const navigate = useNavigate() + const goToChangeRepo = () => + navigate(`/projects/${project.slug}/git/change-repository`) - setSelectedRepository(repo) + // GitLab webhook reinstall + const reinstallWebhook = useMutation({ + ...reinstallGitlabWebhookMutation(), + meta: { errorTitle: 'Failed to reinstall webhook' }, + }) + const isGitlab = currentProvider?.provider_type === 'gitlab' + const hasWebhook = !!project.gitlab_webhook_id - // Update the project with the selected repository + const handleReinstallWebhook = async () => { try { - // Extract just the preset name from "preset::path" format for backend - const formPreset = form.getValues('preset') - const [presetName] = formPreset?.split('::') || [''] - - // Build the update body — public repos use git_url, private repos use connection_id - const body: Record = { - repo_owner: repo.owner, - repo_name: repo.name, - directory: form.getValues('directory') || './', - preset: presetName, - main_branch: - form.getValues('branch') || repo.default_branch || 'main', - } - - if (isPublicRepo) { - body.git_url = `https://github.com/${repo.owner}/${repo.name}` - body.is_public_repo = true - body.git_provider_connection_id = null - } else { - body.git_provider_connection_id = - selectedConnectionId ?? - project.git_provider_connection_id ?? - null - } - - await updateGithubRepo.mutateAsync({ - body: body as any, - path: { project_id: project.id }, - }) - - toast.success('Repository connected successfully') - refetch() - setIsSelectingRepository(false) - - // Update the form values to reflect the new repository - if (repo.default_branch) { - form.setValue('branch', repo.default_branch) - } - } catch (error) { - console.error('Failed to connect repository:', error) - toast.error('Failed to connect repository') - setSelectedRepository(null) + await reinstallWebhook.mutateAsync({ path: { project_id: project.id! } }) + await refetch() + toast.success('Webhook reinstalled') + } catch { + toast.error( + 'Failed to reinstall webhook — check your GitLab user has Maintainer role on the repo.', + ) } } + // Auto-deploy const handleAutoDeployToggle = async (enabled: boolean) => { - if (!project?.id) return - - await toast.promise( - updateAutomaticDeploy.mutateAsync({ + try { + await updateAutomaticDeploy.mutateAsync({ path: { project_id: project.id! }, - body: { - automatic_deploy: enabled, - }, - }), - { - loading: 'Updating deployment settings...', - success: 'Deployment settings updated successfully', - error: 'Failed to update deployment settings', - } + body: { automatic_deploy: enabled }, + }) + await refetch() + toast.success(enabled ? 'Auto-deploy enabled' : 'Auto-deploy disabled') + } catch { + toast.error('Failed to update') + } + } + + // Empty state — no repo connected. + if (!project.repo_owner || !project.repo_name) { + return ( +
+ + +
+ +
+
+

No repository connected

+

+ Connect a Git repository to enable deployments for this project. +

+
+ +
+
+
) - refetch() } - return ( -
-

Git Settings

-

- Manage Git repository settings for your project. -

+ const ghHref = + project.git_url || `https://github.com/${project.repo_owner}/${project.repo_name}` - {project.repo_owner && project.repo_name ? ( -
-
- - - -
-
- Git Settings - - Configure repository, branch, and framework settings. - -
- {!isEditingSettings && ( - - )} -
-
- - {/* Repository Info */} -
-
- - {isEditingSettings && ( - - )} -
- {isSelectingRepository && isEditingSettings ? ( -
- {isPublicRepo ? ( - /* Public repo: show URL input */ -
- - { - const url = e.target.value - setPublicRepoUrl(url) - // Parse GitHub/GitLab URL to extract owner/repo - // Handles: https://github.com/owner/repo, https://github.com/owner/repo.git, - // https://github.com/owner/repo/tree/branch/path, etc. - const match = url.trim().match(/(?:github\.com|gitlab\.com)[/:]([^/\s]+)\/([^/\s.]+)/) - if (match) { - const [, owner, repo] = match - setParsedPublicRepo({ owner, name: repo.replace(/\.git$/, '') }) - } else { - setParsedPublicRepo(null) - } - }} - /> -

- Enter the full URL of a public GitHub or GitLab repository. -

- {parsedPublicRepo && ( -
-

- Detected: {parsedPublicRepo.owner}/{parsedPublicRepo.name} -

- -
- )} -
- ) : ( - /* Private repo: show git provider connection selector */ - <> -
- - -
+ // Helper for displaying short SHA + const shortSha = (sha: string) => sha?.slice(0, 7) - {/* Repository Selection */} - {(selectedConnectionId || - project.git_provider_connection_id) && ( - { - handleRepositorySelect(repo) - setIsSelectingRepository(false) - }} - selectedRepository={selectedRepository} - title="Select New Repository" - description="Choose a repository from your connected git provider" - showAsCard={false} - /> - )} - - )} + const autoDeployOn = project.deployment_config?.automaticDeploy ?? true - -
- ) : ( - <> - -

- {isPublicRepo - ? 'Public repository cloned directly via HTTPS.' - : 'Seamlessly create Deployments for any commits pushed to your Git repository.'} -

- - )} -
- - {/* Git Connection Info — hide for public repos */} - {!!project?.git_provider_connection_id && ( -
- - {currentConnection ? ( -
- {currentProvider?.provider_type === 'github' || - currentProvider?.provider_type === 'github_app' ? ( - - ) : ( - - )} -
-
- - {currentConnection.account_name} - - - {currentProvider?.name} - -
- {currentConnection.created_at && ( -
- Connected{' '} - -
- )} -
-
- ) : ( -
- - No connection found - -
- )} -

- The git provider connection used for this project. -

-
+ return ( +
+ {/* ----------------- Repository card ----------------- */} + + +
+
+ +
+
+
+ + {project.repo_owner}/{project.repo_name} + + + {isPublicRepo ? 'Public' : repositoryData?.private ? 'Private' : 'Connected'} + +
+ {repositoryData?.description && ( +

+ {repositoryData.description} +

+ )} +
+ {repositoryData?.pushed_at && ( + + Pushed + + )} + {repositoryData?.default_branch && ( + + + default {repositoryData.default_branch} + + )} + {currentConnection && ( + + via {currentConnection.account_name}{' '} + ({currentProvider?.name}) + + )} +
+
+
+ +
+
+ +
+
+ + {/* ----------------- Build configuration card ----------------- */} + + + Build configuration + + How Temps builds and deploys this project. + + + +
    + {/* Branch row */} + { + setBranchDraft(project.main_branch || '') + setEditing('branch') + }} + onCancel={close} + onSave={async () => { + if (!branchDraft || branchDraft === project.main_branch) { + close() + return + } + await saveGitField({ main_branch: branchDraft }) + toast.success('Branch updated') + close() + }} + isPending={updateGitSettings.isPending} + display={ +
    + + {project.main_branch} + {repositoryData?.default_branch === project.main_branch && ( + default )} - - {isEditingSettings ? ( - <> - {/* Branch Settings */} - ( - -
    - Main Branch - {project?.repo_owner && project?.repo_name && ( - + {(() => { + const b = branches.find((br) => br.name === project.main_branch) + if (b?.protected) { + return protected + } + return null + })()} + {(() => { + const b = branches.find((br) => br.name === project.main_branch) + if (b?.commit_sha) { + return ( + + {shortSha(b.commit_sha)} + + ) + } + return null + })()} +
    + } + editor={ +
    + {isLoadingBranches ? ( +
    + + Loading branches… +
    + ) : branches.length > 0 ? ( + - ) : !isCustomBranch ? ( - - ) : ( -
    - { - setCustomBranch(e.target.value) - field.onChange(e.target.value) - }} - placeholder="Enter custom branch name" - /> - {branches.length > 0 && ( - - )} -
    + {b.name === repositoryData?.default_branch && ( + )} - - - The default branch to deploy from - - - - )} - /> - - { - // Convert stored preset value to select format - const getSelectValue = () => { - if (field.value === 'custom') return 'custom' - if (!field.value) return '' - - // Get the current directory to match with preset path - const currentDirectory = - form.getValues('directory') || './' - - // Normalize directory for comparison (remove leading ./) - const normalizeDir = (dir: string) => { - if (!dir || dir === '.' || dir === './') - return 'root' - return dir.startsWith('./') ? dir.slice(2) : dir - } - - const normalizedCurrentDir = - normalizeDir(currentDirectory) - - // Find matching preset by both name AND path - const allPresets = effectivePresetData?.presets || [] - const matchingPreset = - allPresets.find((p: any) => { - const normalizedPresetPath = normalizeDir( - p.path - ) - return ( - p.preset === field.value && - normalizedPresetPath === normalizedCurrentDir - ) - }) - - if (matchingPreset) { - return `${matchingPreset.preset}::${normalizeDir(matchingPreset.path)}` - } - - // Fallback: if no exact match, find by preset name only - const fallbackPreset = - allPresets.find( - (p: any) => p.preset === field.value - ) - if (fallbackPreset) { - return `${fallbackPreset.preset}::${normalizeDir(fallbackPreset.path)}` - } - - // Fallback: construct value from slug + current directory - return `${field.value}::${normalizedCurrentDir}` - } - - const selectValue = getSelectValue() - - return ( - - - { - if (value === 'custom') { - field.onChange('custom') - form.setValue('directory', './') - } else { - const [_presetName, presetPath] = - value.split('::') - // Store the full preset::path value for proper selection tracking - field.onChange(value) - - // Treat empty, '.', and 'root' as root directory - if ( - presetPath && - presetPath !== 'root' && - presetPath !== '.' && - presetPath.trim() !== '' - ) { - // Remove leading ./ if present in the path - const cleanPath = presetPath.startsWith( - './' - ) - ? presetPath.slice(2) - : presetPath - form.setValue( - 'directory', - `./${cleanPath}` - ) - } else { - form.setValue('directory', './') - } - } - }} - /> - - - - ) - }} - /> - - { - const isCustomPreset = currentPreset === 'custom' - const canEditDirectory = - isCustomPreset || allowDirectoryOverride - - return ( - -
    - Root Directory - {!isCustomPreset && !allowDirectoryOverride && ( - - )} - {!isCustomPreset && allowDirectoryOverride && ( - - )} -
    - - - - - {canEditDirectory - ? 'Enter the directory path manually' - : 'Directory is set automatically based on selected preset'} - - -
    - ) - }} - /> - - {/* Dockerfile path - only shown when Dockerfile preset is selected */} - {currentPreset?.split('::')[0] === 'dockerfile' && ( - ( - - Dockerfile Path - - - - - Path to your Dockerfile relative to the root - directory - - - - )} - /> - )} - - {/* Compose settings - only shown when Docker Compose preset is selected */} - {(currentPreset?.split('::')[0]?.replace('-', '')?.toLowerCase()?.includes('dockercompose') || - currentPreset?.split('::')[0] === 'docker-compose' || - project?.preset === 'docker-compose' || - project?.preset === 'dockercompose') && ( - <> - ( - - Compose File Path - - - - - Path to your Docker Compose file relative to the - root directory - - - - )} - /> - - ( - - Compose Override (optional) - -