diff --git a/package-lock.json b/package-lock.json index 661fe45..def164b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-tool-manager", - "version": "3.5.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-tool-manager", - "version": "3.5.0", + "version": "3.5.1", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", @@ -16,8 +16,11 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-sql": "^2", "@tauri-apps/plugin-updater": "^2.10.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-svelte": "^0.577.0", - "svelte-dnd-action": "^0.9.69" + "svelte-dnd-action": "^0.9.69", + "xterm": "^5.3.0" }, "devDependencies": { "@playwright/test": "^1.58.0", @@ -266,7 +269,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -307,7 +309,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1180,7 +1181,6 @@ "integrity": "sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1223,7 +1223,6 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -2179,7 +2178,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -2210,12 +2208,26 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2416,7 +2428,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3640,7 +3651,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -4203,7 +4213,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4288,7 +4297,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4716,7 +4724,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.0.tgz", "integrity": "sha512-FR9kTLmX5i0oyeQ5j/+w8DuagIkQ7MWMuPpPVioW2zx9Dw77q+1ufLzF1IqNtcTXPRnIIio4PlasliVn43OnbQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4905,7 +4912,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4968,7 +4974,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5064,7 +5069,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -5302,6 +5306,13 @@ "dev": true, "license": "MIT" }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 14dbc8d..2c2995f 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,11 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-sql": "^2", "@tauri-apps/plugin-updater": "^2.10.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-svelte": "^0.577.0", - "svelte-dnd-action": "^0.9.69" + "svelte-dnd-action": "^0.9.69", + "xterm": "^5.3.0" }, "devDependencies": { "@playwright/test": "^1.58.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 62c3204..8d6c074 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -663,6 +663,7 @@ dependencies = [ "axum", "base64 0.22.1", "bollard", + "bytes", "chrono", "directories", "dirs", @@ -671,6 +672,7 @@ dependencies = [ "futures", "insta", "log", + "once_cell", "pretty_assertions", "pulldown-cmark", "regex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9600ec1..3f30d2a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,8 @@ rusqlite = { version = "0.38", features = ["bundled"] } # Docker bollard = "0.18" +once_cell = "1" +bytes = "1" # Time chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/commands/containers.rs b/src-tauri/src/commands/containers.rs index a5f55e4..8493abf 100644 --- a/src-tauri/src/commands/containers.rs +++ b/src-tauri/src/commands/containers.rs @@ -1,46 +1,213 @@ use crate::db::models::{ Container, ContainerLog, ContainerStats, ContainerStatus, ContainerTemplate, - ContainerWithStatus, CreateContainerRequest, ExecResult, ProjectContainer, + ContainerWithStatus, CreateContainerRequest, ExecResult, PortMapping, ProjectContainer, + VolumeMapping, }; use crate::db::Database; +use crate::services::docker::client::DockerClientManager; +use log::{error, info}; +use rusqlite::params; +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tauri::State; +// ============================================================================ +// JSON parsing helpers +// ============================================================================ + +fn parse_json_array(s: Option) -> Option> { + s.and_then(|v| serde_json::from_str(&v).ok()) +} + +fn parse_json_map(s: Option) -> Option> { + s.and_then(|v| serde_json::from_str(&v).ok()) +} + +fn parse_json_port_mappings(s: Option) -> Option> { + s.and_then(|v| serde_json::from_str(&v).ok()) +} + +fn parse_json_volume_mappings(s: Option) -> Option> { + s.and_then(|v| serde_json::from_str(&v).ok()) +} + +// ============================================================================ +// Security helpers +// ============================================================================ + +/// Validate that a string is a legitimate git URL (HTTPS, SSH, or git:// protocol). +/// Rejects anything that could be used for shell injection. +fn is_valid_git_url(url: &str) -> bool { + let url = url.trim(); + // Reject empty or whitespace-only + if url.is_empty() { + return false; + } + // Reject URLs containing shell metacharacters + if url.contains(';') + || url.contains('&') + || url.contains('|') + || url.contains('$') + || url.contains('`') + || url.contains('(') + || url.contains(')') + || url.contains('\n') + || url.contains('\r') + { + return false; + } + // Allow common git URL patterns + let valid_patterns = [ + url.starts_with("https://"), + url.starts_with("http://"), + url.starts_with("git://"), + url.starts_with("ssh://"), + // git@github.com:user/repo.git style + regex::Regex::new(r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:[\w./-]+$") + .map(|re| re.is_match(url)) + .unwrap_or(false), + ]; + valid_patterns.iter().any(|&v| v) +} + +/// Shell-escape a single argument for use in sh -c commands. +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Validate that a volume host_path does not traverse to sensitive locations. +pub fn validate_volume_host_path(host_path: &str) -> Result<(), String> { + let normalized = host_path.replace('\\', "/"); + // Reject path traversal + if normalized.contains("..") { + return Err(format!( + "Volume host path '{}' contains path traversal (..)", + host_path + )); + } + // Reject sensitive system directories + let sensitive_prefixes = [ + "/etc/shadow", + "/etc/passwd", + "/etc/ssh", + "/root/.ssh", + "/proc", + "/sys", + "C:/Windows/System32", + "C:/Windows/system32", + ]; + for prefix in &sensitive_prefixes { + if normalized.starts_with(prefix) || normalized == *prefix { + return Err(format!( + "Volume host path '{}' points to a sensitive system location", + host_path + )); + } + } + Ok(()) +} + +// ============================================================================ +// Row mapper +// ============================================================================ + +fn row_to_container(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Container { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + container_type: row.get(3)?, + docker_host_id: row.get(4)?, + docker_container_id: row.get(5)?, + image: row.get(6)?, + dockerfile: row.get(7)?, + devcontainer_json: row.get(8)?, + env: parse_json_map(row.get(9)?), + ports: parse_json_port_mappings(row.get(10)?), + volumes: parse_json_volume_mappings(row.get(11)?), + mounts: parse_json_array(row.get(12)?), + features: parse_json_array(row.get(13)?), + post_create_command: row.get(14)?, + post_start_command: row.get(15)?, + working_dir: row.get(16)?, + template_id: row.get(17)?, + repo_url: row.get(18)?, + icon: row.get(19)?, + tags: parse_json_array(row.get(20)?), + is_favorite: row.get::<_, i32>(21)? != 0, + created_at: row.get(22)?, + updated_at: row.get(23)?, + }) +} + +// ============================================================================ +// Tauri command wrappers +// ============================================================================ + #[tauri::command] pub fn get_all_containers(db: State<'_, Arc>>) -> Result, String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Ok(vec![]) + info!("[Container] Loading all containers"); + let db = db.lock().map_err(|e| e.to_string())?; + get_all_containers_impl(&db) } #[tauri::command] pub fn get_container(db: State<'_, Arc>>, id: i64) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id) } #[tauri::command] pub fn create_container( db: State<'_, Arc>>, - request: CreateContainerRequest, + container: CreateContainerRequest, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + info!("[Container] Creating container: {}", container.name); + let db = db.lock().map_err(|e| e.to_string())?; + let result = create_container_impl(&db, &container); + if let Err(ref e) = result { + error!( + "[Container] Failed to create container '{}': {}", + container.name, e + ); + } + result } #[tauri::command] pub fn update_container( db: State<'_, Arc>>, id: i64, - request: CreateContainerRequest, + container: CreateContainerRequest, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + info!("[Container] Updating container id={}", id); + let db = db.lock().map_err(|e| e.to_string())?; + update_container_impl(&db, id, &container) } #[tauri::command] -pub fn delete_container(db: State<'_, Arc>>, id: i64) -> Result<(), String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) +pub async fn delete_container( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + info!("[Container] Deleting container id={}", id); + // Try to remove the Docker container first + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + if let Some(ref docker_id) = container.docker_container_id { + // Force remove (stop + remove) — ignore errors if container doesn't exist + let _ = docker_mgr + .stop_container(docker_id, container.docker_host_id) + .await; + let _ = docker_mgr + .remove_container(docker_id, container.docker_host_id) + .await; + } + let db = db.lock().map_err(|e| e.to_string())?; + delete_container_impl(&db, id) } #[tauri::command] @@ -48,71 +215,456 @@ pub fn toggle_container_favorite( db: State<'_, Arc>>, id: i64, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let db = db.lock().map_err(|e| e.to_string())?; + toggle_container_favorite_impl(&db, id) } #[tauri::command] -pub fn check_docker_available() -> Result { - Ok(false) +pub async fn check_docker_available( + docker_mgr: State<'_, Arc>, +) -> Result { + Ok(docker_mgr.is_docker_available().await) } #[tauri::command] -pub fn build_container_image(id: i64) -> Result { - Err("Container feature not yet implemented".to_string()) +pub async fn build_container_image( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + + let image = container + .image + .as_deref() + .ok_or("No image specified for container")?; + docker_mgr + .pull_image(image, container.docker_host_id) + .await?; + Ok(format!("Pulled image: {}", image)) } #[tauri::command] -pub fn start_container_cmd(id: i64) -> Result<(), String> { - Err("Container feature not yet implemented".to_string()) +pub async fn start_container_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + + // Load Claude settings for container creation + let claude_settings = { + let db = db.lock().map_err(|e| e.to_string())?; + crate::commands::settings::get_container_claude_settings_from_db(&db) + }; + + // If no docker_container_id, pull image and create the container first + let is_first_start; + let docker_id = if let Some(ref did) = container.docker_container_id { + is_first_start = false; + did.clone() + } else { + is_first_start = true; + // Pull the image if specified + if let Some(ref image) = container.image { + info!("[Container] Pulling image before first start: {}", image); + docker_mgr + .pull_image(image, container.docker_host_id) + .await?; + } + + let created_id = docker_mgr + .create_docker_container(&container, Some(&claude_settings)) + .await?; + // Save the docker_container_id back to DB + let db = db.lock().map_err(|e| e.to_string())?; + db.conn() + .execute( + "UPDATE containers SET docker_container_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![created_id, id], + ) + .map_err(|e| e.to_string())?; + created_id + }; + + docker_mgr + .start_container(&docker_id, container.docker_host_id) + .await?; + + // On first start, clone repo if repo_url is set + if is_first_start { + if let Some(ref repo_url) = container.repo_url { + // Validate repo_url to prevent command injection + if !is_valid_git_url(repo_url) { + error!("[Container] Invalid git URL rejected: {}", repo_url); + return Err(format!("Invalid git repository URL: {}", repo_url)); + } + + let working_dir = container.working_dir.as_deref().unwrap_or("/workspace"); + info!("[Container] Cloning repo {} into {}", repo_url, working_dir); + // Use git directly without sh -c to avoid shell injection + let clone_cmd = vec![ + "git".to_string(), + "clone".to_string(), + repo_url.to_string(), + working_dir.to_string(), + ]; + match docker_mgr + .exec(&docker_id, container.docker_host_id, clone_cmd) + .await + { + Ok(result) => { + if result.exit_code != 0 { + // If clone fails because dir isn't empty, try init + fetch + let init_cmd = vec![ + "git".to_string(), + "init".to_string(), + working_dir.to_string(), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, init_cmd) + .await; + + let remote_cmd = vec![ + "git".to_string(), + "-C".to_string(), + working_dir.to_string(), + "remote".to_string(), + "add".to_string(), + "origin".to_string(), + repo_url.to_string(), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, remote_cmd) + .await; + + let fetch_cmd = vec![ + "git".to_string(), + "-C".to_string(), + working_dir.to_string(), + "fetch".to_string(), + "origin".to_string(), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, fetch_cmd) + .await; + + // Try checking out main, fall back to master + let checkout_main = vec![ + "git".to_string(), + "-C".to_string(), + working_dir.to_string(), + "checkout".to_string(), + "-t".to_string(), + "origin/main".to_string(), + ]; + let main_result = docker_mgr + .exec(&docker_id, container.docker_host_id, checkout_main) + .await; + if main_result.map(|r| r.exit_code != 0).unwrap_or(true) { + let checkout_master = vec![ + "git".to_string(), + "-C".to_string(), + working_dir.to_string(), + "checkout".to_string(), + "-t".to_string(), + "origin/master".to_string(), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, checkout_master) + .await; + } + } + // Run post_create_command if set + if let Some(ref post_cmd) = container.post_create_command { + info!("[Container] Running post-create command: {}", post_cmd); + let post_exec = vec![ + "sh".to_string(), + "-c".to_string(), + format!("cd {} && {}", shell_escape(working_dir), post_cmd), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, post_exec) + .await; + } + } + Err(e) => { + error!("[Container] Failed to clone repo: {}", e); + } + } + } else if let Some(ref post_cmd) = container.post_create_command { + // No repo, but still run post_create_command + let working_dir = container.working_dir.as_deref().unwrap_or("/workspace"); + info!("[Container] Running post-create command: {}", post_cmd); + let post_exec = vec![ + "sh".to_string(), + "-c".to_string(), + format!("cd {} && {}", shell_escape(working_dir), post_cmd), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, post_exec) + .await; + } + + // Auto-install Claude Code if enabled + if claude_settings.auto_install { + info!("[Container] Auto-installing Claude Code"); + let install_cmd = vec![ + "sh".to_string(), + "-c".to_string(), + "npm install -g @anthropic-ai/claude-code 2>/dev/null || true".to_string(), + ]; + let _ = docker_mgr + .exec(&docker_id, container.docker_host_id, install_cmd) + .await; + } + } + + Ok(()) } #[tauri::command] -pub fn stop_container_cmd(id: i64) -> Result<(), String> { - Err("Container feature not yet implemented".to_string()) +pub async fn stop_container_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .stop_container(&docker_id, container.docker_host_id) + .await } #[tauri::command] -pub fn restart_container_cmd(id: i64) -> Result<(), String> { - Err("Container feature not yet implemented".to_string()) +pub async fn restart_container_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .restart_container(&docker_id, container.docker_host_id) + .await } #[tauri::command] -pub fn remove_container_cmd(id: i64) -> Result<(), String> { - Err("Container feature not yet implemented".to_string()) +pub async fn remove_container_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + if let Some(ref docker_id) = container.docker_container_id { + docker_mgr + .remove_container(docker_id, container.docker_host_id) + .await?; + } + // Clear the docker_container_id in DB + let db = db.lock().map_err(|e| e.to_string())?; + db.conn() + .execute( + "UPDATE containers SET docker_container_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![id], + ) + .map_err(|e| e.to_string())?; + Ok(()) } #[tauri::command] -pub fn get_container_status(id: i64) -> Result { - Err("Container feature not yet implemented".to_string()) +pub async fn get_container_status( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + + if let Some(ref docker_id) = container.docker_container_id { + docker_mgr + .inspect_container_status(id, docker_id, container.docker_host_id) + .await + } else { + Ok(ContainerStatus { + container_id: id, + docker_status: "not_created".to_string(), + docker_container_id: None, + started_at: None, + finished_at: None, + exit_code: None, + health: None, + cpu_percent: None, + memory_usage: None, + memory_limit: None, + }) + } } #[tauri::command] -pub fn get_all_container_statuses( +pub async fn get_all_container_statuses( db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, ) -> Result, String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Ok(vec![]) + let containers = { + let db = db.lock().map_err(|e| e.to_string())?; + get_all_containers_impl(&db)? + }; + + let mut results = Vec::new(); + for container in containers { + let status = if let Some(ref docker_id) = container.docker_container_id { + docker_mgr + .inspect_container_status(container.id, docker_id, container.docker_host_id) + .await + .unwrap_or(ContainerStatus { + container_id: container.id, + docker_status: "unknown".to_string(), + docker_container_id: container.docker_container_id.clone(), + started_at: None, + finished_at: None, + exit_code: None, + health: None, + cpu_percent: None, + memory_usage: None, + memory_limit: None, + }) + } else { + ContainerStatus { + container_id: container.id, + docker_status: "not_created".to_string(), + docker_container_id: None, + started_at: None, + finished_at: None, + exit_code: None, + health: None, + cpu_percent: None, + memory_usage: None, + memory_limit: None, + } + }; + results.push(ContainerWithStatus { container, status }); + } + Ok(results) +} + +#[tauri::command] +pub async fn get_container_logs_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, + tail: Option, + since: Option, +) -> Result, String> { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .get_logs(&docker_id, container.docker_host_id, tail, since) + .await +} + +#[tauri::command] +pub async fn get_container_stats_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, +) -> Result { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .get_stats(id, &docker_id, container.docker_host_id) + .await +} + +#[tauri::command] +pub async fn exec_in_container_cmd( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + id: i64, + command: Vec, +) -> Result { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .exec(&docker_id, container.docker_host_id, command) + .await } #[tauri::command] -pub fn get_container_logs_cmd(id: i64, tail: Option) -> Result, String> { - Err("Container feature not yet implemented".to_string()) +pub async fn start_container_shell( + db: State<'_, Arc>>, + docker_mgr: State<'_, Arc>, + app_handle: tauri::AppHandle, + id: i64, + session_id: String, +) -> Result { + let container = { + let db = db.lock().map_err(|e| e.to_string())?; + get_container_impl(&db, id)? + }; + let docker_id = container + .docker_container_id + .ok_or("Container has no Docker ID")?; + docker_mgr + .start_interactive_shell(&docker_id, container.docker_host_id, app_handle, session_id) + .await } #[tauri::command] -pub fn get_container_stats_cmd(id: i64) -> Result { - Err("Container feature not yet implemented".to_string()) +pub async fn send_shell_input(session_id: String, data: String) -> Result<(), String> { + crate::services::docker::client::send_shell_input(&session_id, data).await } #[tauri::command] -pub fn exec_in_container_cmd(id: i64, command: String) -> Result { - Err("Container feature not yet implemented".to_string()) +pub async fn resize_shell( + docker_mgr: State<'_, Arc>, + exec_id: String, + host_id: i64, + rows: u16, + cols: u16, +) -> Result<(), String> { + docker_mgr.resize_shell(&exec_id, host_id, rows, cols).await } #[tauri::command] pub fn get_container_templates() -> Result, String> { - Ok(vec![]) + Ok(get_builtin_templates()) } #[tauri::command] @@ -121,8 +673,36 @@ pub fn create_container_from_template( template_id: String, name: String, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let templates = get_builtin_templates(); + let template = templates + .iter() + .find(|t| t.id == template_id) + .ok_or_else(|| format!("Template '{}' not found", template_id))?; + + let request = CreateContainerRequest { + name, + description: Some(template.description.clone()), + container_type: "docker".to_string(), + docker_host_id: None, + image: Some(template.image.clone()), + dockerfile: template.dockerfile.clone(), + devcontainer_json: None, + env: template.env.clone(), + ports: template.ports.clone(), + volumes: template.volumes.clone(), + mounts: None, + features: template.features.clone(), + post_create_command: template.post_create_command.clone(), + post_start_command: template.post_start_command.clone(), + working_dir: template.working_dir.clone(), + template_id: Some(template.id.clone()), + repo_url: None, + icon: Some(template.icon.clone()), + tags: None, + }; + + let db = db.lock().map_err(|e| e.to_string())?; + create_container_impl(&db, &request) } #[tauri::command] @@ -131,8 +711,8 @@ pub fn assign_container_to_project( project_id: i64, container_id: i64, ) -> Result<(), String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let db = db.lock().map_err(|e| e.to_string())?; + assign_container_to_project_impl(&db, project_id, container_id) } #[tauri::command] @@ -141,8 +721,8 @@ pub fn remove_container_from_project( project_id: i64, container_id: i64, ) -> Result<(), String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let db = db.lock().map_err(|e| e.to_string())?; + remove_container_from_project_impl(&db, project_id, container_id) } #[tauri::command] @@ -150,8 +730,8 @@ pub fn get_project_containers( db: State<'_, Arc>>, project_id: i64, ) -> Result, String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Ok(vec![]) + let db = db.lock().map_err(|e| e.to_string())?; + get_project_containers_impl(&db, project_id) } #[tauri::command] @@ -160,6 +740,533 @@ pub fn set_default_project_container( project_id: i64, container_id: i64, ) -> Result<(), String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Container feature not yet implemented".to_string()) + let db = db.lock().map_err(|e| e.to_string())?; + set_default_project_container_impl(&db, project_id, container_id) +} + +// ============================================================================ +// Business logic implementations +// ============================================================================ + +pub(crate) fn get_all_containers_impl(db: &Database) -> Result, String> { + let mut stmt = db + .conn() + .prepare( + "SELECT id, name, description, container_type, docker_host_id, docker_container_id, + image, dockerfile, devcontainer_json, env, ports, volumes, mounts, features, + post_create_command, post_start_command, working_dir, template_id, repo_url, icon, tags, + is_favorite, created_at, updated_at + FROM containers ORDER BY name", + ) + .map_err(|e| e.to_string())?; + + let containers: Vec = stmt + .query_map([], row_to_container) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(containers) +} + +pub(crate) fn get_container_impl(db: &Database, id: i64) -> Result { + let mut stmt = db + .conn() + .prepare( + "SELECT id, name, description, container_type, docker_host_id, docker_container_id, + image, dockerfile, devcontainer_json, env, ports, volumes, mounts, features, + post_create_command, post_start_command, working_dir, template_id, repo_url, icon, tags, + is_favorite, created_at, updated_at + FROM containers WHERE id = ?", + ) + .map_err(|e| e.to_string())?; + + stmt.query_row([id], row_to_container) + .map_err(|e| format!("Container not found: {}", e)) +} + +pub(crate) fn create_container_impl( + db: &Database, + req: &CreateContainerRequest, +) -> Result { + let env_json = req.env.as_ref().map(|e| serde_json::to_string(e).unwrap()); + let ports_json = req + .ports + .as_ref() + .map(|p| serde_json::to_string(p).unwrap()); + let volumes_json = req + .volumes + .as_ref() + .map(|v| serde_json::to_string(v).unwrap()); + let mounts_json = req + .mounts + .as_ref() + .map(|m| serde_json::to_string(m).unwrap()); + let features_json = req + .features + .as_ref() + .map(|f| serde_json::to_string(f).unwrap()); + let tags_json = req.tags.as_ref().map(|t| serde_json::to_string(t).unwrap()); + let docker_host_id = req.docker_host_id.unwrap_or(1); + + db.conn() + .execute( + "INSERT INTO containers (name, description, container_type, docker_host_id, image, + dockerfile, devcontainer_json, env, ports, volumes, mounts, features, + post_create_command, post_start_command, working_dir, template_id, repo_url, icon, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + req.name, + req.description, + req.container_type, + docker_host_id, + req.image, + req.dockerfile, + req.devcontainer_json, + env_json, + ports_json, + volumes_json, + mounts_json, + features_json, + req.post_create_command, + req.post_start_command, + req.working_dir, + req.template_id, + req.repo_url, + req.icon, + tags_json, + ], + ) + .map_err(|e| e.to_string())?; + + let id = db.conn().last_insert_rowid(); + get_container_impl(db, id) +} + +pub(crate) fn update_container_impl( + db: &Database, + id: i64, + req: &CreateContainerRequest, +) -> Result { + let env_json = req.env.as_ref().map(|e| serde_json::to_string(e).unwrap()); + let ports_json = req + .ports + .as_ref() + .map(|p| serde_json::to_string(p).unwrap()); + let volumes_json = req + .volumes + .as_ref() + .map(|v| serde_json::to_string(v).unwrap()); + let mounts_json = req + .mounts + .as_ref() + .map(|m| serde_json::to_string(m).unwrap()); + let features_json = req + .features + .as_ref() + .map(|f| serde_json::to_string(f).unwrap()); + let tags_json = req.tags.as_ref().map(|t| serde_json::to_string(t).unwrap()); + let docker_host_id = req.docker_host_id.unwrap_or(1); + + db.conn() + .execute( + "UPDATE containers SET name = ?, description = ?, container_type = ?, + docker_host_id = ?, image = ?, dockerfile = ?, devcontainer_json = ?, + env = ?, ports = ?, volumes = ?, mounts = ?, features = ?, + post_create_command = ?, post_start_command = ?, working_dir = ?, + template_id = ?, repo_url = ?, icon = ?, tags = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + params![ + req.name, + req.description, + req.container_type, + docker_host_id, + req.image, + req.dockerfile, + req.devcontainer_json, + env_json, + ports_json, + volumes_json, + mounts_json, + features_json, + req.post_create_command, + req.post_start_command, + req.working_dir, + req.template_id, + req.repo_url, + req.icon, + tags_json, + id, + ], + ) + .map_err(|e| e.to_string())?; + + get_container_impl(db, id) +} + +pub(crate) fn delete_container_impl(db: &Database, id: i64) -> Result<(), String> { + db.conn() + .execute("DELETE FROM containers WHERE id = ?", [id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub(crate) fn toggle_container_favorite_impl(db: &Database, id: i64) -> Result { + db.conn() + .execute( + "UPDATE containers SET is_favorite = CASE WHEN is_favorite = 0 THEN 1 ELSE 0 END, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [id], + ) + .map_err(|e| e.to_string())?; + get_container_impl(db, id) +} + +// ============================================================================ +// Project-container junction CRUD +// ============================================================================ + +fn assign_container_to_project_impl( + db: &Database, + project_id: i64, + container_id: i64, +) -> Result<(), String> { + db.conn() + .execute( + "INSERT OR IGNORE INTO project_containers (project_id, container_id) VALUES (?, ?)", + params![project_id, container_id], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn remove_container_from_project_impl( + db: &Database, + project_id: i64, + container_id: i64, +) -> Result<(), String> { + db.conn() + .execute( + "DELETE FROM project_containers WHERE project_id = ? AND container_id = ?", + params![project_id, container_id], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn get_project_containers_impl( + db: &Database, + project_id: i64, +) -> Result, String> { + let mut stmt = db + .conn() + .prepare( + "SELECT pc.id, pc.project_id, pc.container_id, pc.is_default, pc.created_at, + c.id, c.name, c.description, c.container_type, c.docker_host_id, c.docker_container_id, + c.image, c.dockerfile, c.devcontainer_json, c.env, c.ports, c.volumes, c.mounts, + c.features, c.post_create_command, c.post_start_command, c.working_dir, + c.template_id, c.repo_url, c.icon, c.tags, c.is_favorite, c.created_at, c.updated_at + FROM project_containers pc + JOIN containers c ON c.id = pc.container_id + WHERE pc.project_id = ? + ORDER BY pc.is_default DESC, c.name", + ) + .map_err(|e| e.to_string())?; + + let results = stmt + .query_map([project_id], |row| { + let container = Container { + id: row.get(5)?, + name: row.get(6)?, + description: row.get(7)?, + container_type: row.get(8)?, + docker_host_id: row.get(9)?, + docker_container_id: row.get(10)?, + image: row.get(11)?, + dockerfile: row.get(12)?, + devcontainer_json: row.get(13)?, + env: parse_json_map(row.get(14)?), + ports: parse_json_port_mappings(row.get(15)?), + volumes: parse_json_volume_mappings(row.get(16)?), + mounts: parse_json_array(row.get(17)?), + features: parse_json_array(row.get(18)?), + post_create_command: row.get(19)?, + post_start_command: row.get(20)?, + working_dir: row.get(21)?, + template_id: row.get(22)?, + repo_url: row.get(23)?, + icon: row.get(24)?, + tags: parse_json_array(row.get(25)?), + is_favorite: row.get::<_, i32>(26)? != 0, + created_at: row.get(27)?, + updated_at: row.get(28)?, + }; + + Ok(ProjectContainer { + id: row.get(0)?, + project_id: row.get(1)?, + container_id: row.get(2)?, + container, + is_default: row.get::<_, i32>(3)? != 0, + created_at: row.get(4)?, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(results) +} + +fn set_default_project_container_impl( + db: &Database, + project_id: i64, + container_id: i64, +) -> Result<(), String> { + // Reset all defaults for this project + db.conn() + .execute( + "UPDATE project_containers SET is_default = 0 WHERE project_id = ?", + [project_id], + ) + .map_err(|e| e.to_string())?; + + // Set the new default + db.conn() + .execute( + "UPDATE project_containers SET is_default = 1 WHERE project_id = ? AND container_id = ?", + params![project_id, container_id], + ) + .map_err(|e| e.to_string())?; + + Ok(()) +} + +// ============================================================================ +// Built-in container templates +// ============================================================================ + +fn get_builtin_templates() -> Vec { + vec![ + // === General === + ContainerTemplate { + id: "ubuntu-dev".to_string(), + name: "Ubuntu Development".to_string(), + description: "General-purpose Linux dev environment with git, curl, and common tools" + .to_string(), + category: "General".to_string(), + icon: "\u{1F427}".to_string(), + image: "mcr.microsoft.com/devcontainers/base:ubuntu".to_string(), + dockerfile: None, + env: None, + ports: None, + volumes: None, + features: Some(vec![ + "git".to_string(), + "curl".to_string(), + "wget".to_string(), + ]), + post_create_command: None, + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + // === Languages === + ContainerTemplate { + id: "node-dev".to_string(), + name: "Node.js".to_string(), + description: "Node.js 20 with npm, git, and common dev tools".to_string(), + category: "Languages".to_string(), + icon: "\u{1F7E2}".to_string(), + image: "mcr.microsoft.com/devcontainers/javascript-node:20".to_string(), + dockerfile: None, + env: Some(HashMap::from([( + "NODE_ENV".to_string(), + "development".to_string(), + )])), + ports: Some(vec![PortMapping { + host_port: 3000, + container_port: 3000, + protocol: Some("tcp".to_string()), + }]), + volumes: None, + features: None, + post_create_command: None, + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + ContainerTemplate { + id: "typescript-fullstack".to_string(), + name: "TypeScript Full-Stack".to_string(), + description: "TypeScript/Node.js with pnpm, ideal for full-stack web apps".to_string(), + category: "Languages".to_string(), + icon: "\u{1F535}".to_string(), + image: "mcr.microsoft.com/devcontainers/typescript-node:20".to_string(), + dockerfile: None, + env: Some(HashMap::from([( + "NODE_ENV".to_string(), + "development".to_string(), + )])), + ports: Some(vec![ + PortMapping { + host_port: 3000, + container_port: 3000, + protocol: Some("tcp".to_string()), + }, + PortMapping { + host_port: 5173, + container_port: 5173, + protocol: Some("tcp".to_string()), + }, + ]), + volumes: None, + features: None, + post_create_command: Some("npm install -g pnpm typescript".to_string()), + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + ContainerTemplate { + id: "rust-tauri-dev".to_string(), + name: "Rust / Tauri".to_string(), + description: + "Rust with Cargo, clippy, rustfmt, and Tauri CLI for desktop app development" + .to_string(), + category: "Languages".to_string(), + icon: "\u{1F980}".to_string(), + image: "mcr.microsoft.com/devcontainers/rust:latest".to_string(), + dockerfile: None, + env: None, + ports: Some(vec![PortMapping { + host_port: 1420, + container_port: 1420, + protocol: Some("tcp".to_string()), + }]), + volumes: None, + features: None, + post_create_command: Some( + "rustup component add clippy rustfmt && cargo install tauri-cli".to_string(), + ), + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + ContainerTemplate { + id: "python-dev".to_string(), + name: "Python".to_string(), + description: "Python 3.12 with pip, venv support, and common dev tools".to_string(), + category: "Languages".to_string(), + icon: "\u{1F40D}".to_string(), + image: "mcr.microsoft.com/devcontainers/python:3.12".to_string(), + dockerfile: None, + env: Some(HashMap::from([( + "PYTHONDONTWRITEBYTECODE".to_string(), + "1".to_string(), + )])), + ports: Some(vec![PortMapping { + host_port: 8000, + container_port: 8000, + protocol: Some("tcp".to_string()), + }]), + volumes: None, + features: None, + post_create_command: Some("pip install --upgrade pip".to_string()), + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + ContainerTemplate { + id: "go-dev".to_string(), + name: "Go".to_string(), + description: "Go 1.22 with standard toolchain and common dev tools".to_string(), + category: "Languages".to_string(), + icon: "\u{1F439}".to_string(), + image: "mcr.microsoft.com/devcontainers/go:1.22".to_string(), + dockerfile: None, + env: Some(HashMap::from([("GOPATH".to_string(), "/go".to_string())])), + ports: Some(vec![PortMapping { + host_port: 8080, + container_port: 8080, + protocol: Some("tcp".to_string()), + }]), + volumes: None, + features: None, + post_create_command: None, + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + ContainerTemplate { + id: "dotnet-dev".to_string(), + name: ".NET".to_string(), + description: ".NET 8 SDK with C# support for web APIs and apps".to_string(), + category: "Languages".to_string(), + icon: "\u{1F7E3}".to_string(), + image: "mcr.microsoft.com/devcontainers/dotnet:8.0".to_string(), + dockerfile: None, + env: Some(HashMap::from([( + "ASPNETCORE_ENVIRONMENT".to_string(), + "Development".to_string(), + )])), + ports: Some(vec![ + PortMapping { + host_port: 5000, + container_port: 5000, + protocol: Some("tcp".to_string()), + }, + PortMapping { + host_port: 5001, + container_port: 5001, + protocol: Some("tcp".to_string()), + }, + ]), + volumes: None, + features: None, + post_create_command: None, + post_start_command: None, + working_dir: Some("/workspace".to_string()), + }, + // === Databases === + ContainerTemplate { + id: "postgres".to_string(), + name: "PostgreSQL".to_string(), + description: "PostgreSQL 16 database server".to_string(), + category: "Databases".to_string(), + icon: "\u{1F418}".to_string(), + image: "postgres:16-alpine".to_string(), + dockerfile: None, + env: Some(HashMap::from([ + ("POSTGRES_PASSWORD".to_string(), "postgres".to_string()), + ("POSTGRES_DB".to_string(), "devdb".to_string()), + ])), + ports: Some(vec![PortMapping { + host_port: 5432, + container_port: 5432, + protocol: Some("tcp".to_string()), + }]), + volumes: Some(vec![VolumeMapping { + host_path: "pgdata".to_string(), + container_path: "/var/lib/postgresql/data".to_string(), + read_only: Some(false), + }]), + features: None, + post_create_command: None, + post_start_command: None, + working_dir: None, + }, + ContainerTemplate { + id: "redis".to_string(), + name: "Redis".to_string(), + description: "Redis 7 in-memory data store".to_string(), + category: "Databases".to_string(), + icon: "\u{1F534}".to_string(), + image: "redis:7-alpine".to_string(), + dockerfile: None, + env: None, + ports: Some(vec![PortMapping { + host_port: 6379, + container_port: 6379, + protocol: Some("tcp".to_string()), + }]), + volumes: None, + features: None, + post_create_command: None, + post_start_command: None, + working_dir: None, + }, + ] } diff --git a/src-tauri/src/commands/docker_hosts.rs b/src-tauri/src/commands/docker_hosts.rs index c7eb05b..b4b872f 100644 --- a/src-tauri/src/commands/docker_hosts.rs +++ b/src-tauri/src/commands/docker_hosts.rs @@ -1,42 +1,221 @@ use crate::db::models::{CreateDockerHostRequest, DockerHost}; use crate::db::Database; +use crate::services::docker::client::DockerClientManager; +use log::{error, info}; +use rusqlite::params; use std::sync::{Arc, Mutex}; use tauri::State; +// ============================================================================ +// Row mapper +// ============================================================================ + +fn row_to_docker_host(row: &rusqlite::Row) -> rusqlite::Result { + Ok(DockerHost { + id: row.get(0)?, + name: row.get(1)?, + host_type: row.get(2)?, + connection_uri: row.get(3)?, + ssh_key_path: row.get(4)?, + tls_ca_cert: row.get(5)?, + tls_cert: row.get(6)?, + tls_key: row.get(7)?, + is_default: row.get::<_, i32>(8)? != 0, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + +// ============================================================================ +// Tauri command wrappers +// ============================================================================ + #[tauri::command] pub fn get_all_docker_hosts( db: State<'_, Arc>>, ) -> Result, String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Ok(vec![]) + info!("[DockerHost] Loading all docker hosts"); + let db = db.lock().map_err(|e| e.to_string())?; + get_all_docker_hosts_impl(&db) } #[tauri::command] pub fn create_docker_host( db: State<'_, Arc>>, - request: CreateDockerHostRequest, + host: CreateDockerHostRequest, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Docker host feature not yet implemented".to_string()) + info!("[DockerHost] Creating docker host: {}", host.name); + let db = db.lock().map_err(|e| e.to_string())?; + let result = create_docker_host_impl(&db, &host); + if let Err(ref e) = result { + error!( + "[DockerHost] Failed to create docker host '{}': {}", + host.name, e + ); + } + result } #[tauri::command] pub fn update_docker_host( db: State<'_, Arc>>, id: i64, - request: CreateDockerHostRequest, + host: CreateDockerHostRequest, ) -> Result { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Docker host feature not yet implemented".to_string()) + info!("[DockerHost] Updating docker host id={}", id); + let db = db.lock().map_err(|e| e.to_string())?; + update_docker_host_impl(&db, id, &host) } #[tauri::command] pub fn delete_docker_host(db: State<'_, Arc>>, id: i64) -> Result<(), String> { - let _db = db.lock().map_err(|e| e.to_string())?; - Err("Docker host feature not yet implemented".to_string()) + if id == 1 { + return Err("Cannot delete the default local Docker host".to_string()); + } + info!("[DockerHost] Deleting docker host id={}", id); + let db = db.lock().map_err(|e| e.to_string())?; + delete_docker_host_impl(&db, id) } #[tauri::command] -pub fn test_docker_host(id: i64) -> Result { - Err("Docker host feature not yet implemented".to_string()) +pub async fn test_docker_host( + docker_mgr: State<'_, Arc>, + host_type: String, + connection_uri: String, + ssh_key_path: String, +) -> Result { + info!( + "[DockerHost] Testing connection: type={}, uri={}", + host_type, connection_uri + ); + docker_mgr + .ping_host( + &host_type, + if connection_uri.is_empty() { + None + } else { + Some(&connection_uri) + }, + if ssh_key_path.is_empty() { + None + } else { + Some(&ssh_key_path) + }, + ) + .await +} + +// ============================================================================ +// Business logic implementations +// ============================================================================ + +pub(crate) fn get_all_docker_hosts_impl(db: &Database) -> Result, String> { + let mut stmt = db + .conn() + .prepare( + "SELECT id, name, host_type, connection_uri, ssh_key_path, + tls_ca_cert, tls_cert, tls_key, is_default, created_at, updated_at + FROM docker_hosts ORDER BY is_default DESC, name", + ) + .map_err(|e| e.to_string())?; + + let hosts: Vec = stmt + .query_map([], row_to_docker_host) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + Ok(hosts) +} + +pub(crate) fn get_docker_host_impl(db: &Database, id: i64) -> Result { + let mut stmt = db + .conn() + .prepare( + "SELECT id, name, host_type, connection_uri, ssh_key_path, + tls_ca_cert, tls_cert, tls_key, is_default, created_at, updated_at + FROM docker_hosts WHERE id = ?", + ) + .map_err(|e| e.to_string())?; + + stmt.query_row([id], row_to_docker_host) + .map_err(|e| format!("Docker host not found: {}", e)) +} + +fn create_docker_host_impl( + db: &Database, + req: &CreateDockerHostRequest, +) -> Result { + let is_default = req.is_default.unwrap_or(false); + + // If setting as default, clear other defaults first + if is_default { + db.conn() + .execute("UPDATE docker_hosts SET is_default = 0", []) + .map_err(|e| e.to_string())?; + } + + db.conn() + .execute( + "INSERT INTO docker_hosts (name, host_type, connection_uri, ssh_key_path, + tls_ca_cert, tls_cert, tls_key, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + params![ + req.name, + req.host_type, + req.connection_uri, + req.ssh_key_path, + req.tls_ca_cert, + req.tls_cert, + req.tls_key, + is_default as i32, + ], + ) + .map_err(|e| e.to_string())?; + + let id = db.conn().last_insert_rowid(); + get_docker_host_impl(db, id) +} + +fn update_docker_host_impl( + db: &Database, + id: i64, + req: &CreateDockerHostRequest, +) -> Result { + let is_default = req.is_default.unwrap_or(false); + + if is_default { + db.conn() + .execute("UPDATE docker_hosts SET is_default = 0", []) + .map_err(|e| e.to_string())?; + } + + db.conn() + .execute( + "UPDATE docker_hosts SET name = ?, host_type = ?, connection_uri = ?, + ssh_key_path = ?, tls_ca_cert = ?, tls_cert = ?, tls_key = ?, + is_default = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?", + params![ + req.name, + req.host_type, + req.connection_uri, + req.ssh_key_path, + req.tls_ca_cert, + req.tls_cert, + req.tls_key, + is_default as i32, + id, + ], + ) + .map_err(|e| e.to_string())?; + + get_docker_host_impl(db, id) +} + +fn delete_docker_host_impl(db: &Database, id: i64) -> Result<(), String> { + db.conn() + .execute("DELETE FROM docker_hosts WHERE id = ?", [id]) + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e430842..9835569 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -224,6 +224,85 @@ pub fn get_gemini_paths_cmd() -> Result { }) } +// ============================================================================ +// Claude Code container settings +// ============================================================================ + +#[tauri::command] +pub fn get_container_claude_settings( + db: State<'_, Arc>>, +) -> Result { + let db = db.lock().map_err(|e| e.to_string())?; + Ok(get_container_claude_settings_from_db(&db)) +} + +#[tauri::command] +pub fn set_container_claude_settings( + db: State<'_, Arc>>, + settings: ContainerClaudeSettings, +) -> Result<(), String> { + let db = db.lock().map_err(|e| e.to_string())?; + db.set_setting("container_claude_auth_mode", &settings.auth_mode) + .map_err(|e| e.to_string())?; + db.set_setting( + "container_claude_api_key", + &settings.api_key.unwrap_or_default(), + ) + .map_err(|e| e.to_string())?; + db.set_setting( + "container_claude_auto_mount", + if settings.auto_mount_claude_dir { + "true" + } else { + "false" + }, + ) + .map_err(|e| e.to_string())?; + db.set_setting( + "container_claude_auto_install", + if settings.auto_install { + "true" + } else { + "false" + }, + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerClaudeSettings { + pub auth_mode: String, // "max" or "api_key" + pub api_key: Option, + pub auto_mount_claude_dir: bool, // Mount ~/.claude/ into containers + pub auto_install: bool, // Install claude code in post-create +} + +pub fn get_container_claude_settings_from_db(db: &Database) -> ContainerClaudeSettings { + ContainerClaudeSettings { + auth_mode: db + .get_setting("container_claude_auth_mode") + .unwrap_or_else(|| "max".to_string()), + api_key: db + .get_setting("container_claude_api_key") + .filter(|s| !s.is_empty()), + auto_mount_claude_dir: db + .get_setting("container_claude_auto_mount") + .map(|s| s == "true") + .unwrap_or(true), + auto_install: db + .get_setting("container_claude_auto_install") + .map(|s| s == "true") + .unwrap_or(false), + } +} + +/// Get the host's ~/.claude directory path +pub fn get_host_claude_dir() -> Option { + dirs::home_dir().map(|h| h.join(".claude").to_string_lossy().to_string()) +} + // ============================================================================ // Testable helper functions (no Tauri State dependency) // ============================================================================ diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 7384b09..1a15ef8 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -735,6 +735,7 @@ pub struct Container { pub post_start_command: Option, pub working_dir: Option, pub template_id: Option, + pub repo_url: Option, pub icon: Option, pub tags: Option>, pub is_favorite: bool, @@ -800,6 +801,7 @@ pub struct CreateContainerRequest { pub post_start_command: Option, pub working_dir: Option, pub template_id: Option, + pub repo_url: Option, pub icon: Option, pub tags: Option>, } @@ -1700,6 +1702,7 @@ mod tests { post_start_command: None, working_dir: Some("/app".to_string()), template_id: None, + repo_url: None, icon: Some("🐳".to_string()), tags: Some(vec!["dev".to_string()]), is_favorite: false, @@ -1761,6 +1764,7 @@ mod tests { post_start_command: None, working_dir: None, template_id: None, + repo_url: None, icon: None, tags: None, is_favorite: false, @@ -1806,6 +1810,7 @@ mod tests { post_start_command: None, working_dir: None, template_id: None, + repo_url: None, icon: None, tags: None, }; @@ -2459,6 +2464,7 @@ mod tests { post_start_command: None, working_dir: None, template_id: None, + repo_url: None, icon: None, tags: None, is_favorite: false, @@ -2730,6 +2736,7 @@ mod tests { post_start_command: Some("npm run dev".to_string()), working_dir: Some("/app".to_string()), template_id: Some("node-dev".to_string()), + repo_url: Some("https://github.com/example/repo.git".to_string()), icon: Some("🟢".to_string()), tags: Some(vec!["nodejs".to_string()]), }; diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index 8bd63d6..8df0753 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -799,6 +799,7 @@ impl Database { post_start_command TEXT, working_dir TEXT, template_id TEXT, + repo_url TEXT, icon TEXT, tags TEXT, is_favorite INTEGER DEFAULT 0, @@ -825,6 +826,21 @@ impl Database { )?; } + // Migration 17: Add repo_url column to containers table + let has_repo_url: bool = self + .conn + .query_row( + "SELECT COUNT(*) > 0 FROM pragma_table_info('containers') WHERE name = 'repo_url'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if !has_repo_url { + self.conn + .execute("ALTER TABLE containers ADD COLUMN repo_url TEXT", [])?; + } + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 921856e..a553e8b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -347,6 +347,8 @@ pub fn run() { commands::settings::set_github_token, commands::settings::clear_github_token, commands::settings::has_github_token, + commands::settings::get_container_claude_settings, + commands::settings::set_container_claude_settings, // Profile Commands commands::profiles::get_all_profiles, commands::profiles::get_profile, @@ -481,6 +483,9 @@ pub fn run() { commands::containers::get_container_logs_cmd, commands::containers::get_container_stats_cmd, commands::containers::exec_in_container_cmd, + commands::containers::start_container_shell, + commands::containers::send_shell_input, + commands::containers::resize_shell, commands::containers::get_container_templates, commands::containers::create_container_from_template, commands::containers::assign_container_to_project, diff --git a/src-tauri/src/services/docker/client.rs b/src-tauri/src/services/docker/client.rs index 5965752..b5340e1 100644 --- a/src-tauri/src/services/docker/client.rs +++ b/src-tauri/src/services/docker/client.rs @@ -1,15 +1,744 @@ -use std::sync::Mutex; +use bollard::container::{ + Config, CreateContainerOptions, InspectContainerOptions, LogOutput, LogsOptions, + RemoveContainerOptions, RestartContainerOptions, StartContainerOptions, StatsOptions, + StopContainerOptions, +}; +use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecOptions, StartExecResults}; +use bollard::image::CreateImageOptions; +use bollard::Docker; +use bytes::Bytes; +use futures::StreamExt; +use log::{error, info, warn}; +use std::collections::HashMap; +use std::default::Default; +use tauri::Emitter; +use tokio::io::AsyncWriteExt; +use tokio::sync::RwLock; + +use crate::db::models::{Container, ContainerLog, ContainerStats, ContainerStatus, ExecResult}; /// Manages Docker client connections for container operations. -/// Stub implementation for future container management functionality. pub struct DockerClientManager { - _initialized: Mutex, + clients: RwLock>, } impl DockerClientManager { pub fn new() -> Self { Self { - _initialized: Mutex::new(false), + clients: RwLock::new(HashMap::new()), + } + } + + /// Get or create a Docker client for a given host ID. + /// Host ID 1 is always the local Docker daemon. + async fn get_client(&self, host_id: i64) -> Result { + // Check cache first + { + let clients = self.clients.read().await; + if let Some(client) = clients.get(&host_id) { + return Ok(client.clone()); + } + } + + // Create new client - for now only local is supported + let client = Docker::connect_with_local_defaults() + .map_err(|e| format!("Failed to connect to Docker: {}", e))?; + + let mut clients = self.clients.write().await; + clients.insert(host_id, client.clone()); + Ok(client) + } + + /// Connect to Docker based on host parameters (for testing connections) + fn connect_with_params( + host_type: &str, + connection_uri: Option<&str>, + _ssh_key_path: Option<&str>, + ) -> Result { + match host_type { + "local" => Docker::connect_with_local_defaults() + .map_err(|e| format!("Failed to connect to local Docker: {}", e)), + "tcp" => { + let uri = connection_uri.ok_or("TCP connection requires a connection URI")?; + Docker::connect_with_http(uri, 30, bollard::API_DEFAULT_VERSION) + .map_err(|e| format!("Failed to connect to Docker at {}: {}", uri, e)) + } + _ => Err(format!("Unsupported host type: {}", host_type)), + } + } + + /// Check if the local Docker daemon is available + pub async fn is_docker_available(&self) -> bool { + match self.get_client(1).await { + Ok(client) => client.ping().await.is_ok(), + Err(_) => false, + } + } + + /// Ping a Docker host by connection parameters + pub async fn ping_host( + &self, + host_type: &str, + connection_uri: Option<&str>, + ssh_key_path: Option<&str>, + ) -> Result { + let client = Self::connect_with_params(host_type, connection_uri, ssh_key_path)?; + match client.ping().await { + Ok(_) => Ok(true), + Err(e) => { + warn!("[Docker] Ping failed: {}", e); + Ok(false) + } + } + } + + /// Pull a Docker image + pub async fn pull_image(&self, image: &str, host_id: i64) -> Result<(), String> { + let client = self.get_client(host_id).await?; + info!("[Docker] Pulling image: {}", image); + + let (repo, tag) = if let Some(pos) = image.rfind(':') { + (&image[..pos], &image[pos + 1..]) + } else { + (image, "latest") + }; + + let options = CreateImageOptions { + from_image: repo, + tag, + ..Default::default() + }; + + let mut stream = client.create_image(Some(options), None, None); + while let Some(result) = stream.next().await { + match result { + Ok(_info) => {} // Progress update, could log if needed + Err(e) => return Err(format!("Failed to pull image {}: {}", image, e)), + } + } + + info!("[Docker] Successfully pulled image: {}", image); + Ok(()) + } + + /// Create a Docker container from a Container model + pub async fn create_docker_container( + &self, + container: &Container, + claude_settings: Option<&crate::commands::settings::ContainerClaudeSettings>, + ) -> Result { + let client = self.get_client(container.docker_host_id).await?; + + let image = container + .image + .as_deref() + .ok_or("Container has no image specified")?; + + // Build exposed ports and port bindings + let mut exposed_ports = HashMap::new(); + let mut port_bindings = HashMap::new(); + + if let Some(ref ports) = container.ports { + for pm in ports { + let proto = pm.protocol.as_deref().unwrap_or("tcp"); + let container_port = format!("{}/{}", pm.container_port, proto); + exposed_ports.insert(container_port.clone(), HashMap::new()); + port_bindings.insert( + container_port, + Some(vec![bollard::models::PortBinding { + host_ip: Some("127.0.0.1".to_string()), + host_port: Some(pm.host_port.to_string()), + }]), + ); + } + } + + // Build volume binds (with host path validation) + let mut binds = Vec::new(); + if let Some(ref volumes) = container.volumes { + for vm in volumes { + crate::commands::containers::validate_volume_host_path(&vm.host_path)?; + let bind = if vm.read_only.unwrap_or(false) { + format!("{}:{}:ro", vm.host_path, vm.container_path) + } else { + format!("{}:{}", vm.host_path, vm.container_path) + }; + binds.push(bind); + } + } + if let Some(ref mounts) = container.mounts { + binds.extend(mounts.iter().cloned()); + } + + // Build env vars + let mut env: Vec = container + .env + .as_ref() + .map(|e| e.iter().map(|(k, v)| format!("{}={}", k, v)).collect()) + .unwrap_or_default(); + + // Apply Claude Code settings + if let Some(cs) = claude_settings { + // Mount ~/.claude/ for Max plan auth + if cs.auto_mount_claude_dir { + if let Some(host_claude_dir) = crate::commands::settings::get_host_claude_dir() { + let path = std::path::Path::new(&host_claude_dir); + if path.exists() { + // Mount as /root/.claude and /home/node/.claude to cover common user setups (read-only) + binds.push(format!("{}:/root/.claude:ro", host_claude_dir)); + binds.push(format!("{}:/home/node/.claude:ro", host_claude_dir)); + info!( + "[Docker] Auto-mounting Claude auth dir: {}", + host_claude_dir + ); + } + } + } + + // Inject API key if using api_key mode + if cs.auth_mode == "api_key" { + if let Some(ref key) = cs.api_key { + env.push(format!("ANTHROPIC_API_KEY={}", key)); + } + } + } + + let host_config = bollard::models::HostConfig { + port_bindings: if port_bindings.is_empty() { + None + } else { + Some(port_bindings) + }, + binds: if binds.is_empty() { None } else { Some(binds) }, + ..Default::default() + }; + + let config = Config { + image: Some(image.to_string()), + env: if env.is_empty() { None } else { Some(env) }, + exposed_ports: if exposed_ports.is_empty() { + None + } else { + Some(exposed_ports) + }, + working_dir: container.working_dir.as_deref().map(String::from), + host_config: Some(host_config), + // Keep container alive as a dev environment + cmd: Some(vec!["sleep".to_string(), "infinity".to_string()]), + tty: Some(true), + open_stdin: Some(true), + ..Default::default() + }; + + let container_name = format!("cctm-{}", container.name.to_lowercase().replace(' ', "-")); + let options = CreateContainerOptions { + name: &container_name, + platform: None, + }; + + let response = client + .create_container(Some(options), config) + .await + .map_err(|e| format!("Failed to create container: {}", e))?; + + info!("[Docker] Created container: {}", response.id); + Ok(response.id) + } + + /// Start a Docker container + pub async fn start_container(&self, docker_id: &str, host_id: i64) -> Result<(), String> { + let client = self.get_client(host_id).await?; + client + .start_container(docker_id, None::>) + .await + .map_err(|e| format!("Failed to start container: {}", e))?; + info!("[Docker] Started container: {}", docker_id); + Ok(()) + } + + /// Stop a Docker container + pub async fn stop_container(&self, docker_id: &str, host_id: i64) -> Result<(), String> { + let client = self.get_client(host_id).await?; + let options = StopContainerOptions { t: 10 }; + client + .stop_container(docker_id, Some(options)) + .await + .map_err(|e| format!("Failed to stop container: {}", e))?; + info!("[Docker] Stopped container: {}", docker_id); + Ok(()) + } + + /// Restart a Docker container + pub async fn restart_container(&self, docker_id: &str, host_id: i64) -> Result<(), String> { + let client = self.get_client(host_id).await?; + let options = RestartContainerOptions { t: 10 }; + client + .restart_container(docker_id, Some(options)) + .await + .map_err(|e| format!("Failed to restart container: {}", e))?; + info!("[Docker] Restarted container: {}", docker_id); + Ok(()) + } + + /// Remove a Docker container + pub async fn remove_container(&self, docker_id: &str, host_id: i64) -> Result<(), String> { + let client = self.get_client(host_id).await?; + let options = RemoveContainerOptions { + force: true, + ..Default::default() + }; + client + .remove_container(docker_id, Some(options)) + .await + .map_err(|e| format!("Failed to remove container: {}", e))?; + info!("[Docker] Removed container: {}", docker_id); + Ok(()) + } + + /// Inspect a container and return its status + pub async fn inspect_container_status( + &self, + container_id: i64, + docker_id: &str, + host_id: i64, + ) -> Result { + let client = self.get_client(host_id).await?; + let inspect = client + .inspect_container(docker_id, None::) + .await + .map_err(|e| { + // If container not found, return not_created status + if e.to_string().contains("404") || e.to_string().contains("No such container") { + return format!("not_found:{}", e); + } + format!("Failed to inspect container: {}", e) + }); + + match inspect { + Ok(info) => { + let state = info.state.as_ref(); + let docker_status = state + .and_then(|s| s.status) + .map(|s| format!("{:?}", s).to_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(ContainerStatus { + container_id, + docker_status, + docker_container_id: Some(docker_id.to_string()), + started_at: state.and_then(|s| s.started_at.clone()), + finished_at: state.and_then(|s| s.finished_at.clone()), + exit_code: state.and_then(|s| s.exit_code), + health: state + .and_then(|s| s.health.as_ref()) + .and_then(|h| h.status) + .map(|s| format!("{:?}", s).to_lowercase()), + cpu_percent: None, + memory_usage: None, + memory_limit: None, + }) + } + Err(e) if e.starts_with("not_found:") => Ok(ContainerStatus { + container_id, + docker_status: "not_created".to_string(), + docker_container_id: Some(docker_id.to_string()), + started_at: None, + finished_at: None, + exit_code: None, + health: None, + cpu_percent: None, + memory_usage: None, + memory_limit: None, + }), + Err(e) => Err(e), + } + } + + /// Get container logs + pub async fn get_logs( + &self, + docker_id: &str, + host_id: i64, + tail: Option, + since: Option, + ) -> Result, String> { + let client = self.get_client(host_id).await?; + + let tail_str = tail + .map(|t| t.to_string()) + .unwrap_or_else(|| "100".to_string()); + + let options = LogsOptions:: { + stdout: true, + stderr: true, + tail: tail_str, + since: since.unwrap_or(0), + timestamps: true, + ..Default::default() + }; + + let mut stream = client.logs(docker_id, Some(options)); + let mut logs = Vec::new(); + + while let Some(result) = stream.next().await { + match result { + Ok(output) => { + let (stream_type, message) = match output { + LogOutput::StdOut { message } => { + ("stdout", String::from_utf8_lossy(&message).to_string()) + } + LogOutput::StdErr { message } => { + ("stderr", String::from_utf8_lossy(&message).to_string()) + } + LogOutput::Console { message } => { + ("stdout", String::from_utf8_lossy(&message).to_string()) + } + LogOutput::StdIn { message: _ } => continue, + }; + + // Try to extract timestamp from message if timestamps enabled + let (timestamp, msg) = if let Some(space_pos) = message.find(' ') { + let potential_ts = &message[..space_pos]; + if potential_ts.contains('T') || potential_ts.contains('-') { + ( + Some(potential_ts.to_string()), + message[space_pos + 1..].to_string(), + ) + } else { + (None, message) + } + } else { + (None, message) + }; + + logs.push(ContainerLog { + timestamp, + stream: stream_type.to_string(), + message: msg.trim_end().to_string(), + }); + } + Err(e) => { + error!("[Docker] Error reading logs: {}", e); + break; + } + } } + + Ok(logs) + } + + /// Get container stats (single snapshot) + pub async fn get_stats( + &self, + container_id: i64, + docker_id: &str, + host_id: i64, + ) -> Result { + let client = self.get_client(host_id).await?; + + let options = StatsOptions { + stream: false, + one_shot: true, + }; + + let mut stream = client.stats(docker_id, Some(options)); + + if let Some(result) = stream.next().await { + let stats: bollard::container::Stats = + result.map_err(|e| format!("Failed to get stats: {}", e))?; + + // Calculate CPU percentage + let cpu_percent = calculate_cpu_percent(&stats); + + // Memory stats + let memory_usage = stats.memory_stats.usage.unwrap_or(0); + let memory_limit = stats.memory_stats.limit.unwrap_or(0); + let memory_percent = if memory_limit > 0 { + (memory_usage as f64 / memory_limit as f64) * 100.0 + } else { + 0.0 + }; + + // Network stats + let (network_rx, network_tx) = if let Some(ref nets) = stats.networks { + nets.values().fold((0u64, 0u64), |(rx, tx), net| { + (rx + net.rx_bytes, tx + net.tx_bytes) + }) + } else { + (0, 0) + }; + + // Block I/O stats + let (block_read, block_write) = + if let Some(ref entries) = stats.blkio_stats.io_service_bytes_recursive { + entries.iter().fold((0u64, 0u64), |(read, write), entry| { + match entry.op.as_str() { + "read" | "Read" => (read + entry.value, write), + "write" | "Write" => (read, write + entry.value), + _ => (read, write), + } + }) + } else { + (0, 0) + }; + + let pids = stats.pids_stats.current.unwrap_or(0); + + Ok(ContainerStats { + container_id, + cpu_percent, + memory_usage, + memory_limit, + memory_percent, + network_rx_bytes: network_rx, + network_tx_bytes: network_tx, + block_read_bytes: block_read, + block_write_bytes: block_write, + pids, + }) + } else { + Err("No stats returned".to_string()) + } + } + + /// Execute a command in a container + pub async fn exec( + &self, + docker_id: &str, + host_id: i64, + command: Vec, + ) -> Result { + let client = self.get_client(host_id).await?; + + let exec_options = CreateExecOptions { + cmd: Some(command), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }; + + let exec = client + .create_exec(docker_id, exec_options) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + let start_result = client + .start_exec(&exec.id, None) + .await + .map_err(|e| format!("Failed to start exec: {}", e))?; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + if let StartExecResults::Attached { mut output, .. } = start_result { + while let Some(result) = output.next().await { + match result { + Ok(log_output) => match log_output { + LogOutput::StdOut { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + _ => {} + }, + Err(e) => { + error!("[Docker] Exec output error: {}", e); + break; + } + } + } + } + + // Get exit code + let inspect = client + .inspect_exec(&exec.id) + .await + .map_err(|e| format!("Failed to inspect exec: {}", e))?; + + let exit_code = inspect.exit_code.unwrap_or(-1); + + Ok(ExecResult { + exit_code, + stdout, + stderr, + }) + } + + /// Start an interactive shell session in a container + /// Returns the exec ID for resize operations + pub async fn start_interactive_shell( + &self, + docker_id: &str, + host_id: i64, + app_handle: tauri::AppHandle, + session_id: String, + ) -> Result { + let client = self.get_client(host_id).await?; + + let exec_options = CreateExecOptions { + cmd: Some(vec![ + "sh".to_string(), + "-c".to_string(), + "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi".to_string(), + ]), + attach_stdout: Some(true), + attach_stderr: Some(true), + attach_stdin: Some(true), + tty: Some(true), + ..Default::default() + }; + + let exec = client + .create_exec(docker_id, exec_options) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + let exec_id = exec.id.clone(); + + // Resize to reasonable default + let _ = client + .resize_exec( + &exec_id, + ResizeExecOptions { + height: 24, + width: 80, + }, + ) + .await; + + let start_opts = StartExecOptions { + detach: false, + ..Default::default() + }; + + let start_result = client + .start_exec(&exec_id, Some(start_opts)) + .await + .map_err(|e| format!("Failed to start exec: {}", e))?; + + if let StartExecResults::Attached { + mut output, + mut input, + } = start_result + { + let session_id_clone = session_id.clone(); + let app_clone = app_handle.clone(); + + // Spawn task to read output and emit events + tokio::spawn(async move { + while let Some(result) = output.next().await { + match result { + Ok(log_output) => { + let data = match log_output { + LogOutput::StdOut { message } => message, + LogOutput::StdErr { message } => message, + LogOutput::Console { message } => message, + _ => continue, + }; + let _ = app_clone.emit( + &format!("terminal-output-{}", session_id_clone), + String::from_utf8_lossy(&data).to_string(), + ); + } + Err(e) => { + error!("[Docker] Shell output error: {}", e); + break; + } + } + } + let _ = app_clone.emit(&format!("terminal-exit-{}", session_id_clone), ()); + }); + + // Spawn task to listen for input events + let app_clone2 = app_handle.clone(); + let session_id_clone2 = session_id.clone(); + tokio::spawn(async move { + let (tx, mut rx) = tokio::sync::mpsc::channel::(256); + + // Register the sender in a global map + { + let mut senders = SHELL_SENDERS.lock().await; + senders.insert(session_id_clone2.clone(), tx); + } + + while let Some(data) = rx.recv().await { + if let Err(e) = input.write_all(data.as_bytes()).await { + error!("[Docker] Shell input error: {}", e); + break; + } + if let Err(e) = input.flush().await { + error!("[Docker] Shell flush error: {}", e); + break; + } + } + + // Cleanup + { + let mut senders = SHELL_SENDERS.lock().await; + senders.remove(&session_id_clone2); + } + }); + } + + Ok(exec_id) + } + + /// Resize a terminal session + pub async fn resize_shell( + &self, + exec_id: &str, + host_id: i64, + rows: u16, + cols: u16, + ) -> Result<(), String> { + let client = self.get_client(host_id).await?; + client + .resize_exec( + exec_id, + ResizeExecOptions { + height: rows, + width: cols, + }, + ) + .await + .map_err(|e| format!("Failed to resize: {}", e))?; + Ok(()) + } +} + +use once_cell::sync::Lazy; +/// Global map of shell input senders +use tokio::sync::Mutex as TokioMutex; +static SHELL_SENDERS: Lazy>>> = + Lazy::new(|| TokioMutex::new(HashMap::new())); + +/// Send input to a shell session (called from Tauri command) +pub async fn send_shell_input(session_id: &str, data: String) -> Result<(), String> { + let senders = SHELL_SENDERS.lock().await; + if let Some(tx) = senders.get(session_id) { + tx.send(data) + .await + .map_err(|e| format!("Failed to send input: {}", e))?; + Ok(()) + } else { + Err("Shell session not found".to_string()) + } +} + +/// Calculate CPU percentage from Docker stats +fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 { + let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64 + - stats.precpu_stats.cpu_usage.total_usage as f64; + + let system_delta = stats.cpu_stats.system_cpu_usage.unwrap_or(0) as f64 + - stats.precpu_stats.system_cpu_usage.unwrap_or(0) as f64; + + let num_cpus = stats.cpu_stats.online_cpus.unwrap_or(1) as f64; + + if system_delta > 0.0 && cpu_delta >= 0.0 { + (cpu_delta / system_delta) * num_cpus * 100.0 + } else { + 0.0 } } diff --git a/src/lib/components/containers/ContainerActions.svelte b/src/lib/components/containers/ContainerActions.svelte index ac38b2b..54af3ea 100644 --- a/src/lib/components/containers/ContainerActions.svelte +++ b/src/lib/components/containers/ContainerActions.svelte @@ -1,4 +1,6 @@ -
+
-
{displayIcon}
+
{displayIcon}
-
+

{container.name}

- + {typeLabels[container.containerType] || container.containerType} + {#if loading} + + + Working... + + {:else if status} + + {/if}
- {#if container.description} -

{container.description}

- {/if} - {#if container.image} -

{container.image}

+ {#if loading} +

Pulling image and starting container...

+ {:else} + {#if container.description} +

{container.description}

+ {/if} + {#if container.image} +

{container.image}

+ {/if} {/if}
-
+
+ + {#if loading} +
+ +
+ {:else if isRunning} + + + {:else if isStopped || isNotCreated} + + {/if} + + + + + - -
diff --git a/src/lib/components/containers/ContainerDetail.svelte b/src/lib/components/containers/ContainerDetail.svelte index a526e33..5b878d3 100644 --- a/src/lib/components/containers/ContainerDetail.svelte +++ b/src/lib/components/containers/ContainerDetail.svelte @@ -1,6 +1,12 @@ -
-
-
+
+
+

{container.name}

- {#if container.description} -

{container.description}

+ {#if status} + {/if}
-
+
+ + {#if isRunning} + + + {:else} + + {/if} + +
+
+ +{#if actionError} + +{/if} -
- +
+ {#each tabs as tab} - - -
+ role="tab" + aria-selected={activeTab === tab} + aria-controls="panel-{tab}" + class="px-3 py-2 text-sm font-medium -mb-px border-b-2 transition-colors {activeTab === tab + ? 'border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400' + : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}" + onclick={() => activeTab = tab} + >{tab.charAt(0).toUpperCase() + tab.slice(1)} + {/each} +
- {#if activeTab === 'overview'} -
-
-
- Type: - {typeLabels[container.containerType] || container.containerType} -
-
- Image: - {container.image || 'N/A'} -
-
- Working Dir: - {container.workingDir || 'N/A'} -
-
- Container ID: - {container.dockerContainerId || 'N/A'} -
+{#if activeTab === 'overview'} +
+
+
+ Type: + {typeLabels[container.containerType] || container.containerType}
+
+ Image: + {container.image || 'N/A'} +
+
+ Working Dir: + {container.workingDir || 'N/A'} +
+
+ Container ID: + {container.dockerContainerId ? container.dockerContainerId.slice(0, 12) : 'N/A'} +
+
- {#if container.ports && container.ports.length > 0} -
-

Ports

+ {#if container.ports && container.ports.length > 0} +
+

Ports

+
{#each container.ports as port} - {port.hostPort}:{port.containerPort}/{port.protocol} + {port.hostPort}:{port.containerPort}/{port.protocol || 'tcp'} {/each}
- {/if} +
+ {/if} - {#if container.env && Object.keys(container.env).length > 0} -
-

Environment

- {#each Object.entries(container.env) as [key, value]} -
- {key} - = - {value} + {#if container.env && Object.keys(container.env).length > 0} +
+

Environment

+ {#each Object.entries(container.env) as [key, value]} +
+ {key} + = + {value} +
+ {/each} +
+ {/if} + + {#if isRunning} +
+

Connect

+
+
+
+ {dockerExecCmd}
- {/each} + +
+
+
+ {claudeCmd} +
+ +
+

Paste into Warp or any terminal. For Claude, set ANTHROPIC_API_KEY in the container's env vars.

- {/if} -
- {:else if activeTab === 'logs'} +
+ {/if} +
+{:else if activeTab === 'logs'} +
- {:else if activeTab === 'stats'} +
+{:else if activeTab === 'stats'} +
- {:else if activeTab === 'exec'} -
Exec terminal
- {/if} -
+
+{:else if activeTab === 'console'} +
+ {#if isRunning} + + {:else} +
+

Container must be running to use the console

+ +
+ {/if} +
+{:else if activeTab === 'files'} +
+ {#if isRunning} + + {:else} +
+

Container must be running to browse files

+ +
+ {/if} +
+{/if} diff --git a/src/lib/components/containers/ContainerExec.svelte b/src/lib/components/containers/ContainerExec.svelte new file mode 100644 index 0000000..e174c83 --- /dev/null +++ b/src/lib/components/containers/ContainerExec.svelte @@ -0,0 +1,100 @@ + + +
+ +
+ {#if history.length === 0} +

Run a command to see output here

+ {:else} + {#each history as entry} +
+
$ {entry.command}
+ {#if entry.stdout} +
{entry.stdout}
+ {/if} + {#if entry.stderr} +
{entry.stderr}
+ {/if} + {#if entry.exitCode !== 0} +
exit code: {entry.exitCode}
+ {/if} +
+ {/each} + {/if} +
+ + +
+ $ + + +
+
diff --git a/src/lib/components/containers/ContainerFiles.svelte b/src/lib/components/containers/ContainerFiles.svelte new file mode 100644 index 0000000..af77d71 --- /dev/null +++ b/src/lib/components/containers/ContainerFiles.svelte @@ -0,0 +1,158 @@ + + +
+ +
+ +
+

{currentPath}

+
+ +
+ + {#if error} + + {/if} + + {#if isLoading} +
+
+
+ {:else if entries.length === 0 && !error} +

Directory is empty

+ {:else} +
+ + + + + + + + + + {#each entries as entry} + + + + + + {/each} + +
NameSize
+ {#if entry.isDir} + + {:else} +
+ + {entry.name} +
+ {/if} +
+ {entry.isDir ? '—' : formatSize(entry.size)} +
+
+ {/if} +
diff --git a/src/lib/components/containers/ContainerForm.svelte b/src/lib/components/containers/ContainerForm.svelte index 9b19769..591a21a 100644 --- a/src/lib/components/containers/ContainerForm.svelte +++ b/src/lib/components/containers/ContainerForm.svelte @@ -10,6 +10,7 @@ image?: string; workingDir?: string; dockerfile?: string; + repoUrl?: string; postCreateCommand?: string; postStartCommand?: string; icon?: string; @@ -32,65 +33,115 @@ let image = $state(container?.image || ''); let workingDir = $state(container?.workingDir || ''); let dockerfile = $state(container?.dockerfile || ''); + let repoUrl = $state(container?.repoUrl || ''); let postCreateCommand = $state(container?.postCreateCommand || ''); let postStartCommand = $state(container?.postStartCommand || ''); let icon = $state(container?.icon || ''); let tags = $state(container?.tags?.join(', ') || ''); let ports = $state(container?.ports || []); let volumes = $state(container?.volumes || []); + let envEntries = $state>( + container?.env ? Object.entries(container.env).map(([key, value]) => ({ key, value })) : [] + ); const isEditing = $derived(!!container?.id); + + function addEnvVar() { + envEntries = [...envEntries, { key: '', value: '' }]; + } + + function removeEnvVar(index: number) { + envEntries = envEntries.filter((_, i) => i !== index); + } -
{ e.preventDefault(); onSubmit({ name, description, image }); }}> + { + e.preventDefault(); + const parsedTags = tags ? tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined; + onSubmit({ + name, + description: description || undefined, + containerType: container?.containerType || 'docker', + image: image || undefined, + workingDir: workingDir || undefined, + dockerfile: dockerfile || undefined, + repoUrl: repoUrl || undefined, + postCreateCommand: postCreateCommand || undefined, + postStartCommand: postStartCommand || undefined, + icon: icon || undefined, + tags: parsedTags, + ports: ports.length > 0 ? ports : undefined, + volumes: volumes.length > 0 ? volumes : undefined, + env: envEntries.length > 0 ? Object.fromEntries(envEntries.filter(e => e.key).map(e => [e.key, e.value])) : undefined, + }); +}}>
- - + + +
+ +
+ +
- - + +
- - + +
- - + + +

Git repository to clone into the working directory on creation

- - + +
- +
+

Environment Variables

+ +
+ {#each envEntries as entry, i} +
+ + = + + +
+ {/each}
- - + +
- - + +
- - + +
- - + +
diff --git a/src/lib/components/containers/ContainerList.svelte b/src/lib/components/containers/ContainerList.svelte index 037f500..4f91eac 100644 --- a/src/lib/components/containers/ContainerList.svelte +++ b/src/lib/components/containers/ContainerList.svelte @@ -5,13 +5,75 @@ type Props = { onEdit: (container: any) => void; onDelete: (container: any) => void; + onViewDetail?: (container: any) => void; }; - let { onEdit, onDelete }: Props = $props(); + let { onEdit, onDelete, onViewDetail }: Props = $props(); + + function handleFavoriteToggle(container: any) { + containerLibrary.toggleFavorite(container.id); + } + + let actionError = $state(null); + let loadingIds = $state>(new Set()); + + function setLoading(id: number, loading: boolean) { + const next = new Set(loadingIds); + if (loading) next.add(id); else next.delete(id); + loadingIds = next; + } + + async function handleStart(container: any) { + actionError = null; + setLoading(container.id, true); + try { + await containerLibrary.startContainer(container.id); + } catch (err) { + actionError = `Failed to start "${container.name}": ${err instanceof Error ? err.message : String(err)}`; + } finally { + setLoading(container.id, false); + } + } + + async function handleStop(container: any) { + actionError = null; + setLoading(container.id, true); + try { + await containerLibrary.stopContainer(container.id); + } catch (err) { + actionError = `Failed to stop "${container.name}": ${err instanceof Error ? err.message : String(err)}`; + } finally { + setLoading(container.id, false); + } + } + + async function handleRestart(container: any) { + actionError = null; + setLoading(container.id, true); + try { + await containerLibrary.restartContainer(container.id); + } catch (err) { + actionError = `Failed to restart "${container.name}": ${err instanceof Error ? err.message : String(err)}`; + } finally { + setLoading(container.id, false); + } + } + + function getContainerStatus(id: number): string | undefined { + const s = containerLibrary.getStatus(id) as any; + return s?.dockerStatus || s?.docker_status; + } +{#if actionError} + +{/if} + {#if containerLibrary.isLoading} -
+
{:else if containerLibrary.filteredContainers.length === 0} @@ -29,7 +91,18 @@ {:else}
{#each containerLibrary.filteredContainers as container (container.id)} - + {/each}
{/if} diff --git a/src/lib/components/containers/ContainerLogs.svelte b/src/lib/components/containers/ContainerLogs.svelte index b2fe006..3d1c6d7 100644 --- a/src/lib/components/containers/ContainerLogs.svelte +++ b/src/lib/components/containers/ContainerLogs.svelte @@ -1,35 +1,79 @@
-
-
- {#if logs.length === 0} -

No logs available

+ {#if error} + + {/if} + +
+ {#if isLoading} +

Loading logs...

+ {:else if logs.length === 0 && !error} +

No logs available. The container may not have produced any output, or it hasn't been started yet.

{:else} - {#each logs as line} -

{line}

+ {#each logs as log} +

+ {#if log.timestamp}{log.timestamp} {/if}{log.message} +

{/each} {/if}
diff --git a/src/lib/components/containers/ContainerStats.svelte b/src/lib/components/containers/ContainerStats.svelte index 21100a0..27ea6bc 100644 --- a/src/lib/components/containers/ContainerStats.svelte +++ b/src/lib/components/containers/ContainerStats.svelte @@ -1,29 +1,82 @@
{#if isLoading}

Loading stats...

+ {:else if error} +

{error}

{:else if stats} -
-
-

CPU

-

{stats.cpuPercent}%

+
+
+

CPU

+

{stats.cpuPercent.toFixed(1)}%

+
+
+

Memory

+

{formatBytes(stats.memoryUsage)}

+

{stats.memoryPercent.toFixed(1)}% of {formatBytes(stats.memoryLimit)}

+
+
+

Network I/O

+

{formatBytes(stats.networkRxBytes)} / {formatBytes(stats.networkTxBytes)}

-
-

Memory

-

{stats.memoryUsage}

+
+

PIDs

+

{stats.pids}

{:else} -

No stats available

+

No stats available

{/if}
diff --git a/src/lib/components/containers/ContainerTerminal.svelte b/src/lib/components/containers/ContainerTerminal.svelte new file mode 100644 index 0000000..2373369 --- /dev/null +++ b/src/lib/components/containers/ContainerTerminal.svelte @@ -0,0 +1,140 @@ + + +
+ {#if error} + + {/if} + + {#if isConnecting} +
+
+ Connecting to container shell... +
+ {/if} + +
+
diff --git a/src/lib/components/containers/DockerHostForm.svelte b/src/lib/components/containers/DockerHostForm.svelte index 3079e65..69eda52 100644 --- a/src/lib/components/containers/DockerHostForm.svelte +++ b/src/lib/components/containers/DockerHostForm.svelte @@ -14,13 +14,13 @@ { e.preventDefault(); onSubmit({ name, hostType, connectionUri }); }}>
- - + +
- - @@ -29,8 +29,8 @@ {#if hostType !== 'local'}
- - + +
{/if} diff --git a/src/lib/components/containers/DockerHostList.svelte b/src/lib/components/containers/DockerHostList.svelte index 63a857e..443bde1 100644 --- a/src/lib/components/containers/DockerHostList.svelte +++ b/src/lib/components/containers/DockerHostList.svelte @@ -1,5 +1,23 @@
@@ -8,23 +26,23 @@ {:else} {#each containerLibrary.dockerHosts as host (host.id)}
-
+
- {host.name} + {host.name} {#if host.isDefault} Default {/if}
-

+

{host.hostType}{#if host.connectionUri} · {host.connectionUri}{/if}

-
- {#if host.id !== 1} - {/if} @@ -33,3 +51,12 @@ {/each} {/if}
+ + (deletingHostId = null)} +/> diff --git a/src/lib/components/containers/NewContainerWizard.svelte b/src/lib/components/containers/NewContainerWizard.svelte new file mode 100644 index 0000000..c288832 --- /dev/null +++ b/src/lib/components/containers/NewContainerWizard.svelte @@ -0,0 +1,167 @@ + + +{#if step === 'pick'} +
+

Choose a template to get started quickly, or create a custom container from scratch.

+ + + + + + {#if containerLibrary.templates.length > 0} +
+ + {#each categories as cat} + + {/each} +
+ + +
+ {#each filteredTemplates as template (template.id)} + + {/each} +
+ {:else} +

Loading templates...

+ {/if} +
+{:else} +
+ +
+ + {#if selectedTemplate} +
+ {#if getIcon(selectedTemplate)} + {selectedTemplate.name} logo + {:else} + {selectedTemplate.icon} + {/if} + {selectedTemplate.name} + — customize below +
+ {:else} + Custom Container + {/if} +
+ + +
+{/if} diff --git a/src/lib/components/containers/PortMappingEditor.svelte b/src/lib/components/containers/PortMappingEditor.svelte index df14e51..db251a1 100644 --- a/src/lib/components/containers/PortMappingEditor.svelte +++ b/src/lib/components/containers/PortMappingEditor.svelte @@ -30,11 +30,11 @@ {#each ports as port, i}
- + : - + {port.protocol === 'tcp' ? 'TCP' : 'UDP'} - +
{/each}
diff --git a/src/lib/components/containers/TemplateBrowser.svelte b/src/lib/components/containers/TemplateBrowser.svelte index 14cde9c..4bdd735 100644 --- a/src/lib/components/containers/TemplateBrowser.svelte +++ b/src/lib/components/containers/TemplateBrowser.svelte @@ -33,16 +33,16 @@ {#if containerLibrary.templates.length > 0}
{#each categories as cat} {/each} diff --git a/src/lib/components/containers/VolumeMappingEditor.svelte b/src/lib/components/containers/VolumeMappingEditor.svelte index 02987af..b818677 100644 --- a/src/lib/components/containers/VolumeMappingEditor.svelte +++ b/src/lib/components/containers/VolumeMappingEditor.svelte @@ -30,14 +30,14 @@ {#each volumes as volume, i}
- + : - -
{/each}
diff --git a/src/lib/components/containers/index.ts b/src/lib/components/containers/index.ts index 0cbd63d..1161064 100644 --- a/src/lib/components/containers/index.ts +++ b/src/lib/components/containers/index.ts @@ -13,3 +13,7 @@ export { default as TemplateBrowser } from './TemplateBrowser.svelte'; export { default as DockerHostForm } from './DockerHostForm.svelte'; export { default as DockerHostList } from './DockerHostList.svelte'; export { default as ProjectContainerPanel } from './ProjectContainerPanel.svelte'; +export { default as NewContainerWizard } from './NewContainerWizard.svelte'; +export { default as ContainerExec } from './ContainerExec.svelte'; +export { default as ContainerTerminal } from './ContainerTerminal.svelte'; +export { default as ContainerFiles } from './ContainerFiles.svelte'; diff --git a/src/lib/components/settings/settingsCategories.ts b/src/lib/components/settings/settingsCategories.ts index ae378a8..abce461 100644 --- a/src/lib/components/settings/settingsCategories.ts +++ b/src/lib/components/settings/settingsCategories.ts @@ -1,4 +1,4 @@ -import { Sliders, ShieldCheck, Puzzle, Variable, ToggleRight, FileSearch, Clock, KeyRound, ServerCog, Keyboard, RotateCw, Building, Settings } from 'lucide-svelte'; +import { Sliders, ShieldCheck, Puzzle, Variable, ToggleRight, FileSearch, Clock, KeyRound, ServerCog, Keyboard, RotateCw, Building, Settings, Container } from 'lucide-svelte'; export type SettingsCategoryType = 'scoped' | 'standalone'; @@ -22,5 +22,6 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [ { id: 'keybindings', label: 'Keybindings', icon: Keyboard, type: 'standalone' }, { id: 'spinner-verbs', label: 'Spinner Verbs', icon: RotateCw, type: 'standalone' }, { id: 'admin', label: 'Admin', icon: Building, type: 'standalone' }, - { id: 'editor-sync', label: 'Editor Sync', icon: Settings, type: 'standalone' } + { id: 'editor-sync', label: 'Editor Sync', icon: Settings, type: 'standalone' }, + { id: 'containers', label: 'Containers', icon: Container, type: 'standalone' } ]; diff --git a/src/lib/components/settings/tabs/SettingsContainersTab.svelte b/src/lib/components/settings/tabs/SettingsContainersTab.svelte new file mode 100644 index 0000000..7c06a86 --- /dev/null +++ b/src/lib/components/settings/tabs/SettingsContainersTab.svelte @@ -0,0 +1,136 @@ + + +
+ {#if isLoading} +
+
+
+ {:else} +
+

Claude Code in Containers

+

Configure how Claude Code authenticates and installs in your dev containers.

+
+ + +
+ +
+ + +
+
+ + + {#if settings.authMode === 'api_key'} +
+ +
+ + +
+
+ {/if} + + + {#if settings.authMode === 'max'} + + {/if} + + + + + +
+ +
+ {/if} +
diff --git a/src/lib/components/shared/Toast.svelte b/src/lib/components/shared/Toast.svelte index 512952b..68ba9a2 100644 --- a/src/lib/components/shared/Toast.svelte +++ b/src/lib/components/shared/Toast.svelte @@ -15,18 +15,36 @@ info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/50 dark:border-blue-800 dark:text-blue-200', warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/50 dark:border-yellow-800 dark:text-yellow-200' }; + + function handleAction(notification: Notification) { + notification.action?.onclick(); + notifications.remove(notification.id); + }
{#each notifications.notifications as notification (notification.id)}
-
+ + + {#if showDockerHosts} +
+

Docker Hosts

+ +
+ {/if} + + + {#if containerLibrary.containerCount.total > 0} +
+ {containerLibrary.containerCount.total} container{containerLibrary.containerCount.total !== 1 ? 's' : ''} + {#if containerLibrary.containerCount.docker > 0}{containerLibrary.containerCount.docker} Docker{/if} + {#if containerLibrary.containerCount.devcontainer > 0}{containerLibrary.containerCount.devcontainer} Dev Container{/if} + {#if containerLibrary.containerCount.custom > 0}{containerLibrary.containerCount.custom} Custom{/if} +
+ {/if} + + + (editingContainer = container)} + onDelete={(container) => (deletingContainer = container)} + onViewDetail={(container) => { viewingInitialTab = 'overview'; viewingContainer = container; }} + />
+ + +{#if showAddContainer} + +
{ if (e.target === e.currentTarget) { showAddContainer = false; formError = null; } }}> + +
+{/if} + + +{#if editingContainer} + +
{ if (e.target === e.currentTarget) { editingContainer = null; formError = null; } }}> + +
+{/if} + + +{#if viewingContainer} + +
{ if (e.target === e.currentTarget) viewingContainer = null; }}> + +
+{/if} + + (deletingContainer = null)} +/> diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 1bd08a8..da20e53 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -57,7 +57,8 @@ 'keybindings': 'Customize keyboard shortcuts for Claude Code', 'spinner-verbs': 'Customize the action verbs shown in Claude Code\'s spinner', 'admin': 'View enterprise managed settings deployed by your IT administrator', - 'editor-sync': 'Configure editors, servers, tokens, paths, and backups' + 'editor-sync': 'Configure editors, servers, tokens, paths, and backups', + 'containers': 'Configure Claude Code integration for dev containers' }; // Lazy-load tab components — only the active tab's code is fetched @@ -75,6 +76,7 @@ 'spinner-verbs': () => import('$lib/components/settings/tabs/SettingsSpinnerVerbsTab.svelte'), 'admin': () => import('$lib/components/settings/tabs/SettingsAdminTab.svelte'), 'editor-sync': () => import('$lib/components/settings/tabs/SettingsEditorSyncTab.svelte'), + 'containers': () => import('$lib/components/settings/tabs/SettingsContainersTab.svelte'), }; const activeTabPromise = $derived(TAB_LOADERS[activeTab]?.()); diff --git a/src/tests/components/containers.test.ts b/src/tests/components/containers.test.ts index f7561a9..41a4f9f 100644 --- a/src/tests/components/containers.test.ts +++ b/src/tests/components/containers.test.ts @@ -419,9 +419,9 @@ describe('ContainerLogs Component', () => { ContainerLogs = mod.default; }); - it('should render no logs message initially', () => { + it('should render loading message initially', () => { render(ContainerLogs, { props: { containerId: 1 } }); - expect(screen.getByText('No logs available')).toBeInTheDocument(); + expect(screen.getByText('Loading logs...')).toBeInTheDocument(); }); it('should render auto-scroll checkbox', () => { @@ -501,7 +501,8 @@ describe('ContainerDetail Component', () => { expect(screen.getByText('Overview')).toBeInTheDocument(); expect(screen.getByText('Logs')).toBeInTheDocument(); expect(screen.getByText('Stats')).toBeInTheDocument(); - expect(screen.getByText('Exec')).toBeInTheDocument(); + expect(screen.getByText('Console')).toBeInTheDocument(); + expect(screen.getByText('Files')).toBeInTheDocument(); }); it('should show overview tab content by default', () => { @@ -776,7 +777,7 @@ describe('DockerHostList Component', () => { { id: 1, name: 'Local', hostType: 'local', connectionUri: '', isDefault: false } ]; render(DockerHostList); - expect(screen.getByLabelText('Test connection')).toBeInTheDocument(); + expect(screen.getByLabelText('Test connection for Local')).toBeInTheDocument(); }); it('should not render delete button for host id 1', () => { @@ -784,7 +785,7 @@ describe('DockerHostList Component', () => { { id: 1, name: 'Local', hostType: 'local', connectionUri: '', isDefault: false } ]; render(DockerHostList); - expect(screen.queryByLabelText('Delete host')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Delete Local')).not.toBeInTheDocument(); }); it('should render delete button for hosts with id != 1', () => { @@ -792,7 +793,7 @@ describe('DockerHostList Component', () => { { id: 2, name: 'Remote', hostType: 'ssh', connectionUri: 'ssh://user@host', isDefault: false } ]; render(DockerHostList); - expect(screen.getByLabelText('Delete host')).toBeInTheDocument(); + expect(screen.getByLabelText('Delete Remote')).toBeInTheDocument(); }); it('should show host type and connection URI', () => { diff --git a/src/tests/mocks/lucide-svelte.ts b/src/tests/mocks/lucide-svelte.ts index 71a6472..723436f 100644 --- a/src/tests/mocks/lucide-svelte.ts +++ b/src/tests/mocks/lucide-svelte.ts @@ -58,6 +58,8 @@ export const GitBranch = StubIcon; export const GitCompareArrows = StubIcon; export const Globe = StubIcon; export const GripVertical = StubIcon; +export const Hammer = StubIcon; +export const HardDrive = StubIcon; export const Hash = StubIcon; export const Heart = StubIcon; export const History = StubIcon; diff --git a/src/tests/stores/notifications.test.ts b/src/tests/stores/notifications.test.ts index d601670..006d69f 100644 --- a/src/tests/stores/notifications.test.ts +++ b/src/tests/stores/notifications.test.ts @@ -35,7 +35,7 @@ describe('Notifications Store', () => { const { notifications } = await import('$lib/stores/notifications.svelte'); notifications.clear(); - notifications.add('info', 'Custom duration', 10000); + notifications.add('info', 'Custom duration', { duration: 10000 }); expect(notifications.notifications[0].duration).toBe(10000); }); @@ -137,7 +137,7 @@ describe('Notifications Store', () => { const { notifications } = await import('$lib/stores/notifications.svelte'); notifications.clear(); - notifications.add('error', 'Persistent notification', 0); + notifications.add('error', 'Persistent notification', { duration: 0 }); vi.advanceTimersByTime(10000); @@ -148,7 +148,7 @@ describe('Notifications Store', () => { const { notifications } = await import('$lib/stores/notifications.svelte'); notifications.clear(); - notifications.add('success', 'Auto dismiss', 3000); + notifications.add('success', 'Auto dismiss', { duration: 3000 }); expect(notifications.notifications).toHaveLength(1); diff --git a/static/icons/templates/dotnet.svg b/static/icons/templates/dotnet.svg new file mode 100644 index 0000000..a0f53a7 --- /dev/null +++ b/static/icons/templates/dotnet.svg @@ -0,0 +1 @@ +.NET diff --git a/static/icons/templates/go.svg b/static/icons/templates/go.svg new file mode 100644 index 0000000..80fc18d --- /dev/null +++ b/static/icons/templates/go.svg @@ -0,0 +1 @@ +Go diff --git a/static/icons/templates/nodejs.svg b/static/icons/templates/nodejs.svg new file mode 100644 index 0000000..c9b2bbd --- /dev/null +++ b/static/icons/templates/nodejs.svg @@ -0,0 +1 @@ +Node.js diff --git a/static/icons/templates/postgresql.svg b/static/icons/templates/postgresql.svg new file mode 100644 index 0000000..5de15d1 --- /dev/null +++ b/static/icons/templates/postgresql.svg @@ -0,0 +1 @@ +PostgreSQL diff --git a/static/icons/templates/python.svg b/static/icons/templates/python.svg new file mode 100644 index 0000000..fbf9dc9 --- /dev/null +++ b/static/icons/templates/python.svg @@ -0,0 +1 @@ +Python diff --git a/static/icons/templates/redis.svg b/static/icons/templates/redis.svg new file mode 100644 index 0000000..803b36f --- /dev/null +++ b/static/icons/templates/redis.svg @@ -0,0 +1 @@ +Redis diff --git a/static/icons/templates/rust.svg b/static/icons/templates/rust.svg new file mode 100644 index 0000000..ece35ff --- /dev/null +++ b/static/icons/templates/rust.svg @@ -0,0 +1 @@ +Rust diff --git a/static/icons/templates/tauri.svg b/static/icons/templates/tauri.svg new file mode 100644 index 0000000..9eb2362 --- /dev/null +++ b/static/icons/templates/tauri.svg @@ -0,0 +1 @@ +Tauri diff --git a/static/icons/templates/typescript.svg b/static/icons/templates/typescript.svg new file mode 100644 index 0000000..1829f4b --- /dev/null +++ b/static/icons/templates/typescript.svg @@ -0,0 +1 @@ +TypeScript diff --git a/static/icons/templates/ubuntu.svg b/static/icons/templates/ubuntu.svg new file mode 100644 index 0000000..eeb7e7c --- /dev/null +++ b/static/icons/templates/ubuntu.svg @@ -0,0 +1 @@ +Ubuntu