Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<plugin>/...` 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 `<Command>` 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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/temps-agents/src/services/config_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/temps-agents/src/services/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
122 changes: 119 additions & 3 deletions crates/temps-deployer/src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,66 @@ pub struct DockerRuntime {
overlay_peers: Option<Arc<std::sync::RwLock<Vec<temps_network::Peer>>>>,
}

/// 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<bool>,
exit_code: Option<i64>,
error: Option<&str>,
) -> Option<String> {
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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<i32> = 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<chrono::DateTime<chrono::Utc>> {
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
Expand All @@ -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| {
Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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(),
})
}
Expand Down
84 changes: 84 additions & 0 deletions crates/temps-deployer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
/// 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<i32>,
/// 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<String>,
/// True when Docker's OOM killer terminated the container.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub oom_killed: Option<bool>,
/// Error string captured from Docker's container state on exit.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error_message: Option<String>,
/// When the container exited (Docker's FinishedAt). None while still running.
#[schema(value_type = Option<String>, example = "2025-10-12T12:16:47.609192Z")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub finished_at: Option<UtcDateTime>,
/// 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<String>, example = "2025-10-12T12:15:50.000000Z")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub started_at: Option<UtcDateTime>,
/// 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<f64>,
}

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)
Expand All @@ -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<f64>,
/// Memory usage in bytes
pub memory_bytes: u64,
/// Memory limit in bytes (if set)
Expand All @@ -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<i64>,
/// 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<UtcDateTime>,
/// 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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading