diff --git a/src-tauri/src/commands/docker.rs b/src-tauri/src/commands/docker.rs new file mode 100644 index 0000000..34ee5e2 --- /dev/null +++ b/src-tauri/src/commands/docker.rs @@ -0,0 +1,666 @@ +use crate::error::{TuskError, TuskResult}; +use crate::models::connection::ConnectionConfig; +use crate::models::docker::{ + CloneMode, CloneProgress, CloneResult, CloneToDockerParams, DockerStatus, TuskContainer, +}; +use crate::state::AppState; +use std::fs; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::process::Command; + +fn docker_err(msg: impl Into) -> TuskError { + TuskError::Docker(msg.into()) +} + +fn emit_progress( + app: &AppHandle, + clone_id: &str, + stage: &str, + percent: u8, + message: &str, + detail: Option<&str>, +) { + let _ = app.emit( + "clone-progress", + CloneProgress { + clone_id: clone_id.to_string(), + stage: stage.to_string(), + percent, + message: message.to_string(), + detail: detail.map(|s| s.to_string()), + }, + ); +} + +fn get_connections_path(app: &AppHandle) -> TuskResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| TuskError::Custom(e.to_string()))?; + fs::create_dir_all(&dir)?; + Ok(dir.join("connections.json")) +} + +fn load_connection_config(app: &AppHandle, connection_id: &str) -> TuskResult { + let path = get_connections_path(app)?; + if !path.exists() { + return Err(TuskError::ConnectionNotFound(connection_id.to_string())); + } + let data = fs::read_to_string(&path)?; + let connections: Vec = serde_json::from_str(&data)?; + connections + .into_iter() + .find(|c| c.id == connection_id) + .ok_or_else(|| TuskError::ConnectionNotFound(connection_id.to_string())) +} + +/// Shell-escape a string for use in single quotes +fn shell_escape(s: &str) -> String { + s.replace('\'', "'\\''") +} + +#[tauri::command] +pub async fn check_docker() -> TuskResult { + let output = Command::new("docker") + .args(["version", "--format", "{{.Server.Version}}"]) + .output() + .await; + + match output { + Ok(out) => { + if out.status.success() { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(DockerStatus { + installed: true, + daemon_running: true, + version: Some(version), + error: None, + }) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let daemon_running = !stderr.contains("Cannot connect") + && !stderr.contains("connection refused"); + Ok(DockerStatus { + installed: true, + daemon_running, + version: None, + error: Some(stderr), + }) + } + } + Err(_) => Ok(DockerStatus { + installed: false, + daemon_running: false, + version: None, + error: Some("Docker CLI not found. Please install Docker.".to_string()), + }), + } +} + +#[tauri::command] +pub async fn list_tusk_containers() -> TuskResult> { + let output = Command::new("docker") + .args([ + "ps", + "-a", + "--filter", + "label=tusk.managed=true", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label \"tusk.pg-version\"}}\t{{.Label \"tusk.source-db\"}}\t{{.Label \"tusk.source-connection\"}}\t{{.CreatedAt}}\t{{.Ports}}", + ]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to run docker ps: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(docker_err(format!("docker ps failed: {}", stderr))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut containers = Vec::new(); + + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 8 { + continue; + } + + let host_port = parse_host_port(parts[7]); + + containers.push(TuskContainer { + container_id: parts[0].to_string(), + name: parts[1].to_string(), + status: parts[2].to_string(), + host_port, + pg_version: parts[3].to_string(), + source_database: if parts[4].is_empty() { + None + } else { + Some(parts[4].to_string()) + }, + source_connection: if parts[5].is_empty() { + None + } else { + Some(parts[5].to_string()) + }, + created_at: if parts[6].is_empty() { + None + } else { + Some(parts[6].to_string()) + }, + }); + } + + Ok(containers) +} + +fn parse_host_port(ports_str: &str) -> u16 { + for part in ports_str.split(',') { + let part = part.trim(); + if let Some(arrow_pos) = part.find("->") { + let before = &part[..arrow_pos]; + if let Some(colon_pos) = before.rfind(':') { + if let Ok(port) = before[colon_pos + 1..].parse::() { + return port; + } + } + } + } + 0 +} + +#[tauri::command] +pub async fn clone_to_docker( + app: AppHandle, + state: State<'_, Arc>, + params: CloneToDockerParams, + clone_id: String, +) -> TuskResult { + let state = state.inner().clone(); + let app_clone = app.clone(); + + tokio::spawn(async move { do_clone(&app_clone, &state, ¶ms, &clone_id).await }) + .await + .map_err(|e| docker_err(format!("Clone task panicked: {}", e)))? +} + +async fn do_clone( + app: &AppHandle, + _state: &Arc, + params: &CloneToDockerParams, + clone_id: &str, +) -> TuskResult { + // Step 1: Check Docker + emit_progress(app, clone_id, "checking", 5, "Checking Docker availability...", None); + let status = check_docker().await?; + if !status.installed || !status.daemon_running { + let msg = status + .error + .unwrap_or_else(|| "Docker is not available".to_string()); + emit_progress(app, clone_id, "error", 5, &msg, None); + return Err(docker_err(msg)); + } + + // Step 2: Find available port + emit_progress(app, clone_id, "port", 10, "Finding available port...", None); + let host_port = match params.host_port { + Some(p) => p, + None => find_free_port().await?, + }; + emit_progress(app, clone_id, "port", 10, &format!("Using port {}", host_port), None); + + // Step 3: Create container + emit_progress(app, clone_id, "container", 20, "Creating PostgreSQL container...", None); + let pg_password = params.postgres_password.as_deref().unwrap_or("tusk"); + let image = format!("postgres:{}", params.pg_version); + + let create_output = Command::new("docker") + .args([ + "run", "-d", + "--name", ¶ms.container_name, + "-p", &format!("{}:5432", host_port), + "-e", &format!("POSTGRES_PASSWORD={}", pg_password), + "-l", "tusk.managed=true", + "-l", &format!("tusk.source-db={}", params.source_database), + "-l", &format!("tusk.source-connection={}", params.source_connection_id), + "-l", &format!("tusk.pg-version={}", params.pg_version), + &image, + ]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to create container: {}", e)))?; + + if !create_output.status.success() { + let stderr = String::from_utf8_lossy(&create_output.stderr).trim().to_string(); + emit_progress(app, clone_id, "error", 20, &format!("Failed to create container: {}", stderr), None); + return Err(docker_err(format!("Failed to create container: {}", stderr))); + } + + let container_id = String::from_utf8_lossy(&create_output.stdout).trim().to_string(); + + // Step 4: Wait for PostgreSQL to be ready + emit_progress(app, clone_id, "waiting", 30, "Waiting for PostgreSQL to be ready...", None); + wait_for_pg_ready(¶ms.container_name, 30).await?; + emit_progress(app, clone_id, "waiting", 35, "PostgreSQL is ready", None); + + // Step 5: Create target database + emit_progress(app, clone_id, "database", 35, &format!("Creating database '{}'...", params.source_database), None); + let create_db_output = Command::new("docker") + .args([ + "exec", ¶ms.container_name, + "psql", "-U", "postgres", "-c", + &format!("CREATE DATABASE \"{}\"", params.source_database), + ]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to create database: {}", e)))?; + + if !create_db_output.status.success() { + let stderr = String::from_utf8_lossy(&create_db_output.stderr).trim().to_string(); + if !stderr.contains("already exists") { + emit_progress(app, clone_id, "error", 35, &format!("Failed to create database: {}", stderr), None); + return Err(docker_err(format!("Failed to create database: {}", stderr))); + } + } + + // Step 6: Get source connection URL (using the specific database to clone) + emit_progress(app, clone_id, "dump", 40, "Preparing data transfer...", None); + let source_config = load_connection_config(app, ¶ms.source_connection_id)?; + let source_url = source_config.connection_url_for_db(¶ms.source_database); + emit_progress( + app, clone_id, "dump", 40, + &format!("Source: {}@{}:{}/{}", source_config.user, source_config.host, source_config.port, params.source_database), + None, + ); + + // Step 7: Transfer data based on clone mode + match params.clone_mode { + CloneMode::SchemaOnly => { + emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); + transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + } + CloneMode::FullClone => { + emit_progress(app, clone_id, "transfer", 45, "Performing full database clone...", None); + transfer_full_clone(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + } + CloneMode::SampleData => { + emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); + transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + emit_progress(app, clone_id, "transfer", 65, "Copying sample data...", None); + let sample_rows = params.sample_rows.unwrap_or(1000); + transfer_sample_data(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, sample_rows).await?; + } + } + + // Step 8: Save connection in Tusk + emit_progress(app, clone_id, "connection", 90, "Saving connection...", None); + let connection_id = uuid::Uuid::new_v4().to_string(); + let new_config = ConnectionConfig { + id: connection_id.clone(), + name: format!("{} (Docker clone)", params.source_database), + host: "localhost".to_string(), + port: host_port, + user: "postgres".to_string(), + password: pg_password.to_string(), + database: params.source_database.clone(), + ssl_mode: Some("disable".to_string()), + color: Some("#06b6d4".to_string()), + environment: Some("local".to_string()), + }; + + save_connection_config(app, &new_config)?; + + let connection_url = format!( + "postgres://postgres:{}@localhost:{}/{}", + pg_password, host_port, params.source_database + ); + + let container = TuskContainer { + container_id: container_id[..12.min(container_id.len())].to_string(), + name: params.container_name.clone(), + status: "Up".to_string(), + host_port, + pg_version: params.pg_version.clone(), + source_database: Some(params.source_database.clone()), + source_connection: Some(params.source_connection_id.clone()), + created_at: Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()), + }; + + let result = CloneResult { + container, + connection_id, + connection_url, + }; + + emit_progress(app, clone_id, "done", 100, "Clone completed successfully!", None); + + Ok(result) +} + +async fn find_free_port() -> TuskResult { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| docker_err(format!("Failed to find free port: {}", e)))?; + let port = listener + .local_addr() + .map_err(|e| docker_err(format!("Failed to get port: {}", e)))? + .port(); + drop(listener); + Ok(port) +} + +async fn wait_for_pg_ready(container_name: &str, timeout_secs: u64) -> TuskResult<()> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + + loop { + if start.elapsed() > timeout { + return Err(docker_err("PostgreSQL did not become ready in time")); + } + + let output = Command::new("docker") + .args(["exec", container_name, "pg_isready", "-U", "postgres"]) + .output() + .await; + + if let Ok(out) = output { + if out.status.success() { + return Ok(()); + } + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + +async fn try_local_pg_dump() -> bool { + Command::new("pg_dump") + .arg("--version") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Build the pg_dump portion of a shell command +fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source_url: &str) -> String { + let escaped_url = shell_escape(source_url); + if has_local { + format!("pg_dump {} '{}'", extra_args, escaped_url) + } else { + format!( + "docker run --rm --network=host postgres:{} pg_dump {} '{}'", + pg_version, extra_args, escaped_url + ) + } +} + +async fn transfer_schema_only( + app: &AppHandle, + clone_id: &str, + source_url: &str, + container_name: &str, + database: &str, + pg_version: &str, +) -> TuskResult<()> { + let has_local = try_local_pg_dump().await; + let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; + emit_progress(app, clone_id, "transfer", 50, &format!("Using {} for schema...", label), None); + + let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only", source_url); + let escaped_db = shell_escape(database); + let pipe_cmd = format!( + "{} | docker exec -i '{}' psql -U postgres -d '{}'", + dump_cmd, shell_escape(container_name), escaped_db + ); + + let output = Command::new("sh") + .args(["-c", &pipe_cmd]) + .output() + .await + .map_err(|e| docker_err(format!("Schema transfer failed: {}", e)))?; + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if !output.status.success() { + // psql often emits non-fatal warnings; only fail on actual errors + if stderr.contains("FATAL") || stderr.contains("could not connect") || stderr.contains("No such file") { + emit_progress(app, clone_id, "error", 55, "Schema transfer failed", Some(&stderr)); + return Err(docker_err(format!("Schema transfer failed: {}", stderr))); + } + } + + // Log any output for debugging + if !stderr.is_empty() { + emit_progress(app, clone_id, "transfer", 55, "Schema transferred with warnings", Some(&stderr)); + } + if !stdout.is_empty() { + // Count CREATE statements to give user feedback + let creates = stdout.lines().filter(|l| l.starts_with("CREATE") || l.starts_with("ALTER")).count(); + if creates > 0 { + emit_progress(app, clone_id, "transfer", 58, &format!("Applied {} DDL statements", creates), None); + } + } + + emit_progress(app, clone_id, "transfer", 60, "Schema transferred successfully", None); + Ok(()) +} + +async fn transfer_full_clone( + app: &AppHandle, + clone_id: &str, + source_url: &str, + container_name: &str, + database: &str, + pg_version: &str, +) -> TuskResult<()> { + let has_local = try_local_pg_dump().await; + let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; + emit_progress(app, clone_id, "transfer", 50, &format!("Using {} for full clone...", label), None); + + let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "-Fc", source_url); + let escaped_db = shell_escape(database); + let pipe_cmd = format!( + "{} | docker exec -i '{}' pg_restore -U postgres -d '{}' --no-owner", + dump_cmd, shell_escape(container_name), escaped_db + ); + + let output = Command::new("sh") + .args(["-c", &pipe_cmd]) + .output() + .await + .map_err(|e| docker_err(format!("Full clone failed: {}", e)))?; + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if !output.status.success() { + if stderr.contains("FATAL") || stderr.contains("could not connect") { + emit_progress(app, clone_id, "error", 55, "Full clone failed", Some(&stderr)); + return Err(docker_err(format!("Full clone failed: {}", stderr))); + } + } + + // pg_restore often emits warnings about ownership/permissions — log them + if !stderr.is_empty() { + emit_progress(app, clone_id, "transfer", 80, "Clone completed with warnings", Some(&stderr)); + } + + emit_progress(app, clone_id, "transfer", 85, "Full clone completed", None); + Ok(()) +} + +async fn transfer_sample_data( + app: &AppHandle, + clone_id: &str, + source_url: &str, + container_name: &str, + database: &str, + pg_version: &str, + sample_rows: u32, +) -> TuskResult<()> { + // List tables from the target (schema already transferred) + let target_output = Command::new("docker") + .args([ + "exec", container_name, + "psql", "-U", "postgres", "-d", database, + "-t", "-A", "-c", + "SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema') ORDER BY schemaname, tablename", + ]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to list tables: {}", e)))?; + + let tables_str = String::from_utf8_lossy(&target_output.stdout); + let tables: Vec<&str> = tables_str.lines().filter(|l| !l.trim().is_empty()).collect(); + let total = tables.len(); + + if total == 0 { + emit_progress(app, clone_id, "transfer", 85, "No tables to copy data for", None); + return Ok(()); + } + + let has_local = try_local_pg_dump().await; + + for (i, qualified_table) in tables.iter().enumerate() { + let pct = 65 + ((i * 20) / total.max(1)).min(20) as u8; + emit_progress( + app, clone_id, "transfer", pct, + &format!("Copying sample data: {} ({}/{})", qualified_table, i + 1, total), + None, + ); + + let parts: Vec<&str> = qualified_table.splitn(2, '.').collect(); + if parts.len() != 2 { + continue; + } + let schema = parts[0]; + let table = parts[1]; + + // Use COPY (SELECT ... LIMIT N) TO STDOUT piped to COPY ... FROM STDIN + let copy_out_sql = format!( + "\\copy (SELECT * FROM \\\"{}\\\".\\\"{}\\\" LIMIT {}) TO STDOUT", + schema, table, sample_rows + ); + let copy_in_sql = format!( + "\\copy \\\"{}\\\".\\\"{}\\\" FROM STDIN", + schema, table + ); + + let escaped_url = shell_escape(source_url); + let escaped_container = shell_escape(container_name); + let escaped_db = shell_escape(database); + + let source_cmd = if has_local { + format!("psql '{}' -c \"{}\"", escaped_url, copy_out_sql) + } else { + let image = format!("postgres:{}", pg_version); + format!( + "docker run --rm --network=host {} psql '{}' -c \"{}\"", + image, escaped_url, copy_out_sql + ) + }; + + let pipe_cmd = format!( + "{} | docker exec -i '{}' psql -U postgres -d '{}' -c \"{}\"", + source_cmd, escaped_container, escaped_db, copy_in_sql + ); + + let output = Command::new("sh") + .args(["-c", &pipe_cmd]) + .output() + .await; + + match output { + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if !stderr.is_empty() && (stderr.contains("ERROR") || stderr.contains("FATAL")) { + emit_progress( + app, clone_id, "transfer", pct, + &format!("Warning: {}", qualified_table), + Some(&stderr), + ); + } + } + Err(e) => { + emit_progress( + app, clone_id, "transfer", pct, + &format!("Warning: failed to copy {}: {}", qualified_table, e), + None, + ); + } + } + } + + emit_progress(app, clone_id, "transfer", 85, "Sample data transfer completed", None); + Ok(()) +} + +fn save_connection_config(app: &AppHandle, config: &ConnectionConfig) -> TuskResult<()> { + let path = get_connections_path(app)?; + let mut connections = if path.exists() { + let data = fs::read_to_string(&path)?; + serde_json::from_str::>(&data)? + } else { + vec![] + }; + + connections.push(config.clone()); + + let data = serde_json::to_string_pretty(&connections)?; + fs::write(&path, data)?; + Ok(()) +} + +#[tauri::command] +pub async fn start_container(name: String) -> TuskResult<()> { + let output = Command::new("docker") + .args(["start", &name]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to start container: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(docker_err(format!("Failed to start container: {}", stderr))); + } + + Ok(()) +} + +#[tauri::command] +pub async fn stop_container(name: String) -> TuskResult<()> { + let output = Command::new("docker") + .args(["stop", &name]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to stop container: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(docker_err(format!("Failed to stop container: {}", stderr))); + } + + Ok(()) +} + +#[tauri::command] +pub async fn remove_container(name: String) -> TuskResult<()> { + let output = Command::new("docker") + .args(["rm", "-f", &name]) + .output() + .await + .map_err(|e| docker_err(format!("Failed to remove container: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(docker_err(format!("Failed to remove container: {}", stderr))); + } + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 28cecbe..0e2df4e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod ai; pub mod connections; pub mod data; +pub mod docker; pub mod export; pub mod history; pub mod lookup; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 1f5c048..8ed58ca 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -23,6 +23,9 @@ pub enum TuskError { #[error("AI error: {0}")] Ai(String), + #[error("Docker error: {0}")] + Docker(String), + #[error("{0}")] Custom(String), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db9498c..a64f27c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,6 +100,13 @@ pub fn run() { commands::ai::fix_sql_error, // lookup commands::lookup::entity_lookup, + // docker + commands::docker::check_docker, + commands::docker::list_tusk_containers, + commands::docker::clone_to_docker, + commands::docker::start_container, + commands::docker::stop_container, + commands::docker::remove_container, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index 03c0d47..dca2f03 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -16,6 +16,10 @@ pub struct ConnectionConfig { impl ConnectionConfig { pub fn connection_url(&self) -> String { + self.connection_url_for_db(&self.database) + } + + pub fn connection_url_for_db(&self, database: &str) -> String { let ssl = self.ssl_mode.as_deref().unwrap_or("prefer"); format!( "postgres://{}:{}@{}:{}/{}?sslmode={}", @@ -23,7 +27,7 @@ impl ConnectionConfig { urlencoded(&self.password), self.host, self.port, - urlencoded(&self.database), + urlencoded(database), ssl ) } diff --git a/src-tauri/src/models/docker.rs b/src-tauri/src/models/docker.rs new file mode 100644 index 0000000..71f96f2 --- /dev/null +++ b/src-tauri/src/models/docker.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerStatus { + pub installed: bool, + pub daemon_running: bool, + pub version: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CloneMode { + SchemaOnly, + FullClone, + SampleData, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CloneToDockerParams { + pub source_connection_id: String, + pub source_database: String, + pub container_name: String, + pub pg_version: String, + pub host_port: Option, + pub clone_mode: CloneMode, + pub sample_rows: Option, + pub postgres_password: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CloneProgress { + pub clone_id: String, + pub stage: String, + pub percent: u8, + pub message: String, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuskContainer { + pub container_id: String, + pub name: String, + pub status: String, + pub host_port: u16, + pub pg_version: String, + pub source_database: Option, + pub source_connection: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CloneResult { + pub container: TuskContainer, + pub connection_id: String, + pub connection_url: String, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 98a9071..f114683 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod ai; pub mod connection; +pub mod docker; pub mod history; pub mod lookup; pub mod management; diff --git a/src/components/docker/CloneDatabaseDialog.tsx b/src/components/docker/CloneDatabaseDialog.tsx new file mode 100644 index 0000000..5e07d59 --- /dev/null +++ b/src/components/docker/CloneDatabaseDialog.tsx @@ -0,0 +1,494 @@ +import { useState, useEffect, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { useDockerStatus, useCloneToDocker } from "@/hooks/use-docker"; +import { toast } from "sonner"; +import { + Loader2, + CheckCircle2, + XCircle, + Container, + Copy, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import type { CloneMode, CloneProgress } from "@/types"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; + database: string; + onConnect?: (connectionId: string) => void; +} + +type Step = "config" | "progress" | "done"; + +function ProcessLog({ + entries, + open: logOpen, + onToggle, + endRef, +}: { + entries: CloneProgress[]; + open: boolean; + onToggle: () => void; + endRef: React.RefObject; +}) { + if (entries.length === 0) return null; + + return ( +
+ + {logOpen && ( +
+ {entries.map((entry, i) => ( +
+ + {entry.percent}% + + {entry.message} + {entry.detail && ( + + — {entry.detail} + + )} +
+ ))} +
+
+ )} +
+ ); +} + +export function CloneDatabaseDialog({ + open, + onOpenChange, + connectionId, + database, + onConnect, +}: Props) { + const [step, setStep] = useState("config"); + const [containerName, setContainerName] = useState(""); + const [pgVersion, setPgVersion] = useState("16"); + const [portMode, setPortMode] = useState<"auto" | "manual">("auto"); + const [manualPort, setManualPort] = useState(5433); + const [cloneMode, setCloneMode] = useState("schema_only"); + const [sampleRows, setSampleRows] = useState(1000); + + const [logEntries, setLogEntries] = useState([]); + const [logOpen, setLogOpen] = useState(false); + const logEndRef = useRef(null); + + const { data: dockerStatus } = useDockerStatus(); + const { clone, result, error, isCloning, progress, reset } = + useCloneToDocker(); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setStep("config"); + setContainerName( + `tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${Date.now().toString(36)}` + ); + setPgVersion("16"); + setPortMode("auto"); + setManualPort(5433); + setCloneMode("schema_only"); + setSampleRows(1000); + setLogEntries([]); + setLogOpen(false); + reset(); + } + }, [open, database, reset]); + + // Accumulate progress events into log + useEffect(() => { + if (progress) { + setLogEntries((prev) => { + const last = prev[prev.length - 1]; + if (last && last.stage === progress.stage && last.message === progress.message) { + return prev; + } + return [...prev, progress]; + }); + if (progress.stage === "done" || progress.stage === "error") { + setStep("done"); + } + } + }, [progress]); + + // Auto-scroll log to bottom + useEffect(() => { + if (logOpen && logEndRef.current) { + logEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logEntries, logOpen]); + + const handleClone = () => { + if (!containerName.trim()) { + toast.error("Container name is required"); + return; + } + + setStep("progress"); + + const cloneId = crypto.randomUUID(); + clone({ + params: { + source_connection_id: connectionId, + source_database: database, + container_name: containerName.trim(), + pg_version: pgVersion, + host_port: portMode === "manual" ? manualPort : null, + clone_mode: cloneMode, + sample_rows: cloneMode === "sample_data" ? sampleRows : null, + postgres_password: null, + }, + cloneId, + }); + }; + + const handleConnect = () => { + if (result?.connection_id && onConnect) { + onConnect(result.connection_id); + } + onOpenChange(false); + }; + + const dockerReady = + dockerStatus?.installed && dockerStatus?.daemon_running; + + const logSection = ( + setLogOpen(!logOpen)} + endRef={logEndRef} + /> + ); + + return ( + + + + + + Clone to Docker + + + + {step === "config" && ( + <> +
+ {dockerStatus === undefined ? ( + <> + + + Checking Docker... + + + ) : dockerReady ? ( + <> + + Docker {dockerStatus.version} + + ) : ( + <> + + + {dockerStatus?.error || "Docker not available"} + + + )} +
+ +
+
+ +
+ {database} +
+
+ +
+ + setContainerName(e.target.value)} + placeholder="tusk-mydb-clone" + /> +
+ +
+ + +
+ +
+ +
+ + {portMode === "manual" && ( + + setManualPort(parseInt(e.target.value) || 5433) + } + min={1024} + max={65535} + /> + )} +
+
+ +
+ + +
+ + {cloneMode === "sample_data" && ( +
+ + + setSampleRows(parseInt(e.target.value) || 1000) + } + min={1} + max={100000} + /> +
+ )} +
+ + + + + + + )} + + {step === "progress" && ( +
+
+
+ {progress?.message || "Starting..."} + + {progress?.percent ?? 0}% + +
+
+
+
+
+ + {isCloning && ( +
+ + {progress?.stage || "Initializing..."} +
+ )} + + {logSection} +
+ )} + + {step === "done" && ( +
+ {error ? ( +
+ +
+

+ Clone Failed +

+

{error}

+
+
+ ) : ( +
+
+ +
+

+ Clone Completed +

+

+ Database cloned to Docker container successfully. +

+
+
+ + {result && ( +
+
+ + Container + + + {result.container.name} + +
+
+ Port + + {result.container.host_port} + +
+
+ URL +
+ + {result.connection_url} + + +
+
+
+ )} +
+ )} + + {logSection} + + + {error ? ( + <> + + + + ) : ( + <> + + {onConnect && result && ( + + )} + + )} + +
+ )} + +
+ ); +} diff --git a/src/components/docker/DockerContainersList.tsx b/src/components/docker/DockerContainersList.tsx new file mode 100644 index 0000000..887c627 --- /dev/null +++ b/src/components/docker/DockerContainersList.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { + useTuskContainers, + useStartContainer, + useStopContainer, + useRemoveContainer, + useDockerStatus, +} from "@/hooks/use-docker"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + ChevronDown, + ChevronRight, + Container, + Play, + Square, + Trash2, + Loader2, +} from "lucide-react"; + +export function DockerContainersList() { + const [expanded, setExpanded] = useState(true); + const { data: dockerStatus } = useDockerStatus(); + const { data: containers, isLoading } = useTuskContainers(); + const startMutation = useStartContainer(); + const stopMutation = useStopContainer(); + const removeMutation = useRemoveContainer(); + + const dockerAvailable = + dockerStatus?.installed && dockerStatus?.daemon_running; + + if (!dockerAvailable) { + return null; + } + + const handleStart = (name: string) => { + startMutation.mutate(name, { + onSuccess: () => toast.success(`Container "${name}" started`), + onError: (err) => + toast.error("Failed to start container", { + description: String(err), + }), + }); + }; + + const handleStop = (name: string) => { + stopMutation.mutate(name, { + onSuccess: () => toast.success(`Container "${name}" stopped`), + onError: (err) => + toast.error("Failed to stop container", { + description: String(err), + }), + }); + }; + + const handleRemove = (name: string) => { + if ( + !confirm( + `Remove container "${name}"? This will delete the container and all its data.` + ) + ) { + return; + } + removeMutation.mutate(name, { + onSuccess: () => toast.success(`Container "${name}" removed`), + onError: (err) => + toast.error("Failed to remove container", { + description: String(err), + }), + }); + }; + + const isRunning = (status: string) => + status.toLowerCase().startsWith("up"); + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + Docker Clones + {containers && containers.length > 0 && ( + + {containers.length} + + )} +
+ + {expanded && ( +
+ {isLoading && ( +
+ Loading... +
+ )} + {containers && containers.length === 0 && ( +
+ No Docker clones yet. Right-click a database to clone it. +
+ )} + {containers?.map((container) => ( +
+ + {container.name} + + {container.source_database && ( + + {container.source_database} + + )} + + :{container.host_port} + + + {isRunning(container.status) ? "running" : "stopped"} + +
+ {isRunning(container.status) ? ( + + ) : ( + + )} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/management/AdminPanel.tsx b/src/components/management/AdminPanel.tsx index fbf78fa..d19c73a 100644 --- a/src/components/management/AdminPanel.tsx +++ b/src/components/management/AdminPanel.tsx @@ -23,6 +23,7 @@ import { Activity, Loader2, } from "lucide-react"; +import { DockerContainersList } from "@/components/docker/DockerContainersList"; import type { Tab, RoleInfo } from "@/types"; export function AdminPanel() { @@ -72,6 +73,7 @@ export function AdminPanel() { addTab(tab); }} /> +
); } diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx index 9b00ee2..9d047bf 100644 --- a/src/components/schema/SchemaTree.tsx +++ b/src/components/schema/SchemaTree.tsx @@ -31,6 +31,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog"; +import { CloneDatabaseDialog } from "@/components/docker/CloneDatabaseDialog"; import type { Tab, SchemaObject } from "@/types"; function formatSize(bytes: number): string { @@ -65,6 +66,7 @@ export function SchemaTree() { const { data: databases } = useDatabases(activeConnectionId); const { data: connections } = useConnections(); const switchDbMutation = useSwitchDatabase(); + const [cloneTarget, setCloneTarget] = useState(null); if (!activeConnectionId) { return ( @@ -112,6 +114,7 @@ export function SchemaTree() { connectionId={activeConnectionId} onSwitch={() => handleSwitchDb(db)} isSwitching={switchDbMutation.isPending} + onCloneToDocker={(dbName) => setCloneTarget(dbName)} onOpenTable={(schema, table) => { const tab: Tab = { id: crypto.randomUUID(), @@ -149,6 +152,12 @@ export function SchemaTree() { }} /> ))} + { if (!open) setCloneTarget(null); }} + connectionId={activeConnectionId} + database={cloneTarget ?? ""} + /> ); } @@ -159,6 +168,7 @@ function DatabaseNode({ connectionId, onSwitch, isSwitching, + onCloneToDocker, onOpenTable, onViewStructure, onViewErd, @@ -168,6 +178,7 @@ function DatabaseNode({ connectionId: string; onSwitch: () => void; isSwitching: boolean; + onCloneToDocker: (dbName: string) => void; onOpenTable: (schema: string, table: string) => void; onViewStructure: (schema: string, table: string) => void; onViewErd: (schema: string) => void; @@ -231,6 +242,9 @@ function DatabaseNode({ > Properties + onCloneToDocker(name)}> + Clone to Docker + (null); + const cloneIdRef = useRef(""); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: ({ + params, + cloneId, + }: { + params: CloneToDockerParams; + cloneId: string; + }) => { + cloneIdRef.current = cloneId; + setProgress(null); + return cloneToDocker(params, cloneId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["connections"] }); + queryClient.invalidateQueries({ queryKey: ["tusk-containers"] }); + }, + }); + + useEffect(() => { + const unlistenPromise = onCloneProgress((p) => { + if (p.clone_id === cloneIdRef.current) { + setProgress(p); + } + }); + return () => { + unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); + + const mutationRef = useRef(mutation); + mutationRef.current = mutation; + + const reset = useCallback(() => { + mutationRef.current.reset(); + setProgress(null); + cloneIdRef.current = ""; + }, []); + + return { + clone: mutation.mutate, + result: mutation.data as CloneResult | undefined, + error: mutation.error ? String(mutation.error) : null, + isCloning: mutation.isPending, + progress, + reset, + }; +} + +export function useStartContainer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => startContainer(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tusk-containers"] }); + }, + }); +} + +export function useStopContainer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => stopContainer(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tusk-containers"] }); + }, + }); +} + +export function useRemoveContainer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => removeContainer(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tusk-containers"] }); + }, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index c944f86..7d2af45 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -28,6 +28,11 @@ import type { OllamaModel, EntityLookupResult, LookupProgress, + DockerStatus, + CloneToDockerParams, + CloneProgress, + CloneResult, + TuskContainer, } from "@/types"; // Connections @@ -291,3 +296,27 @@ export const onLookupProgress = ( callback: (p: LookupProgress) => void ): Promise => listen("lookup-progress", (e) => callback(e.payload)); + +// Docker +export const checkDocker = () => + invoke("check_docker"); + +export const listTuskContainers = () => + invoke("list_tusk_containers"); + +export const cloneToDocker = (params: CloneToDockerParams, cloneId: string) => + invoke("clone_to_docker", { params, cloneId }); + +export const startContainer = (name: string) => + invoke("start_container", { name }); + +export const stopContainer = (name: string) => + invoke("stop_container", { name }); + +export const removeContainer = (name: string) => + invoke("remove_container", { name }); + +export const onCloneProgress = ( + callback: (p: CloneProgress) => void +): Promise => + listen("clone-progress", (e) => callback(e.payload)); diff --git a/src/types/index.ts b/src/types/index.ts index 99f0c81..7d0e80a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -323,6 +323,52 @@ export interface ErdData { relationships: ErdRelationship[]; } +// Docker +export interface DockerStatus { + installed: boolean; + daemon_running: boolean; + version: string | null; + error: string | null; +} + +export type CloneMode = "schema_only" | "full_clone" | "sample_data"; + +export interface CloneToDockerParams { + source_connection_id: string; + source_database: string; + container_name: string; + pg_version: string; + host_port: number | null; + clone_mode: CloneMode; + sample_rows: number | null; + postgres_password: string | null; +} + +export interface CloneProgress { + clone_id: string; + stage: string; + percent: number; + message: string; + detail: string | null; +} + +export interface TuskContainer { + container_id: string; + name: string; + status: string; + host_port: number; + pg_version: string; + source_database: string | null; + source_connection: string | null; + created_at: string | null; +} + +export interface CloneResult { + container: TuskContainer; + connection_id: string; + connection_url: string; +} + export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup" | "erd"; export interface Tab {