From d07dcdfea9fdacece06bb17716bcf4b9b7b0e0ff Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 13:48:04 -0700 Subject: [PATCH] Add "Backup" action to download a project's /workspace as .tar.gz Adds a manual backup button on each project card (next to Start/Reset when stopped, and next to Files when running) that saves a gzipped tarball of the container's /workspace to a host path via the native save dialog. Backend: download_container_backup runs `tar czf -` inside the container (so excludes + compression happen there rather than streaming a 16 GB workspace) and pipes stdout straight to the chosen file. Regenerable build artifacts (node_modules, target, .git/objects) are excluded so the archive stays restore-sized. Returns bytes written; stderr is captured for error reporting and a zero-byte result is treated as failure. Works whether the container is running or stopped (only requires that it exists). Verified on the Ubuntu/GNU-tar container base. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/commands/file_commands.rs | 114 +++++++++++++++++++- app/src-tauri/src/lib.rs | 1 + app/src/components/projects/ProjectCard.tsx | 35 +++++- app/src/lib/tauri-commands.ts | 2 + 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 37b2ef6..cc312c8 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -1,4 +1,5 @@ -use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions}; +use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions}; +use bollard::exec::{CreateExecOptions, StartExecResults}; use futures_util::StreamExt; use serde::Serialize; use tauri::State; @@ -151,6 +152,117 @@ pub async fn download_container_file( Ok(()) } +/// Create a `.tar.gz` backup of the container's /workspace and stream it to a +/// host file. Regenerable build artifacts (node_modules, target, .git/objects) +/// are excluded so the archive stays restore-sized. Requires the container to +/// exist (it can be stopped or running). Returns the number of bytes written. +#[tauri::command] +pub async fn download_container_backup( + project_id: String, + host_path: String, + container_path: Option, + state: State<'_, AppState>, +) -> Result { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; + + let docker = get_docker()?; + let path = container_path.unwrap_or_else(|| "/workspace".to_string()); + + // Build + gzip the archive inside the container (excludes happen there, so a + // 16 GB workspace doesn't get streamed in full), then pipe stdout to the + // chosen host file. --ignore-failed-read keeps a transient unreadable file + // from aborting the whole backup. + let cmd = vec![ + "tar".to_string(), + "czf".to_string(), + "-".to_string(), + "--ignore-failed-read".to_string(), + "--exclude=*/node_modules".to_string(), + "--exclude=*/target".to_string(), + "--exclude=*/.git/objects".to_string(), + "-C".to_string(), + path, + ".".to_string(), + ]; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(true), + cmd: Some(cmd), + user: Some("claude".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create backup exec: {}", e))?; + + let result = docker + .start_exec(&exec.id, None) + .await + .map_err(|e| format!("Failed to start backup exec: {}", e))?; + + let mut output = match result { + StartExecResults::Attached { output, .. } => output, + StartExecResults::Detached => return Err("Backup exec started detached".to_string()), + }; + + use std::io::Write; + let file = + std::fs::File::create(&host_path).map_err(|e| format!("Failed to create backup file: {}", e))?; + let mut writer = std::io::BufWriter::new(file); + let mut total: u64 = 0; + let mut stderr_text = String::new(); + + while let Some(msg) = output.next().await { + match msg.map_err(|e| format!("Backup stream error: {}", e))? { + LogOutput::StdOut { message } => { + writer + .write_all(&message) + .map_err(|e| format!("Failed to write backup file: {}", e))?; + total += message.len() as u64; + } + LogOutput::StdErr { message } => { + stderr_text.push_str(&String::from_utf8_lossy(&message)); + } + _ => {} + } + } + writer + .flush() + .map_err(|e| format!("Failed to finalize backup file: {}", e))?; + + if total == 0 { + let _ = std::fs::remove_file(&host_path); + return Err(format!( + "Backup produced no data{}", + if stderr_text.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + + log::info!( + "Wrote {} byte backup for project {} to {}", + total, + project_id, + host_path + ); + Ok(total) +} + #[tauri::command] pub async fn upload_file_to_container( project_id: String, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5b041e5..7d42655 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -184,6 +184,7 @@ pub fn run() { // Files commands::file_commands::list_container_files, commands::file_commands::download_container_file, + commands::file_commands::download_container_backup, commands::file_commands::upload_file_to_container, // MCP commands::mcp_commands::list_mcp_servers, diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index a3e575d..ef40b4a 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import * as commands from "../../lib/tauri-commands"; import { listen } from "@tauri-apps/api/event"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; @@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) { const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [operationCompleted, setOperationCompleted] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); + const [backingUp, setBackingUp] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(project.name); const isSelected = selectedProjectId === project.id; @@ -177,6 +179,31 @@ export default function ProjectCard({ project }: Props) { } }; + const handleBackup = async () => { + if (!project.container_id) { + setError("Start the project at least once before backing up."); + return; + } + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_"); + try { + const hostPath = await save({ + defaultPath: `${safeName}-backup-${stamp}.tar.gz`, + filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }], + }); + if (!hostPath) return; + setBackingUp(true); + setError(null); + const bytes = await commands.downloadContainerBackup(project.id, hostPath); + const mb = (bytes / (1024 * 1024)).toFixed(1); + setProgressMsg(`Backup saved (${mb} MB)`); + } catch (e) { + setError(String(e)); + } finally { + setBackingUp(false); + } + }; + const closeModal = () => { setActiveOperation(null); setOperationCompleted(false); @@ -484,6 +511,11 @@ export default function ProjectCard({ project }: Props) { {isStopped ? ( <> + { setLoading(true); @@ -504,6 +536,7 @@ export default function ProjectCard({ project }: Props) { setShowFileManager(true)} disabled={loading} label="Files" /> + ) : ( <> diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 4fdd6da..c0f7a84 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -76,6 +76,8 @@ export const listContainerFiles = (projectId: string, path: string) => invoke("list_container_files", { projectId, path }); export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) => invoke("download_container_file", { projectId, containerPath, hostPath }); +export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) => + invoke("download_container_backup", { projectId, hostPath, containerPath }); export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) => invoke("upload_file_to_container", { projectId, hostPath, containerDir });