diff --git a/.gitignore b/.gitignore index c10c543..08e543b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/dist /dumps /.env /restore-assistant.log diff --git a/README.md b/README.md index 36e994e..606eeb2 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,45 @@ Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para u ## Requisitos -- Rust stable - Ferramentas PostgreSQL no `PATH`: - `psql` - `pg_restore` - Acesso ao bucket MinIO/S3 com API compatível com S3 +- Rust stable, apenas para compilar a partir do código-fonte ## Instalação +### A partir do pacote do Gitea + +Baixe a versão publicada no Generic Package Registry, extraia e instale no `PATH`: + +```bash +VERSION=0.1.0 +ARCHIVE="restoredb-x86_64-unknown-linux-gnu.tar.gz" + +curl -L -o "$ARCHIVE" \ + "https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb/$VERSION/$ARCHIVE" + +tar -xzf "$ARCHIVE" +sudo install -m 0755 restoredb /usr/local/bin/restoredb +``` + +Confira: + +```bash +restoredb --help +``` + +Se o pacote privado exigir autenticação, use um token do Gitea: + +```bash +GITEA_TOKEN=seu_token +curl -L -u "rogeriolima:$GITEA_TOKEN" -o "$ARCHIVE" \ + "https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb/$VERSION/$ARCHIVE" +``` + +### Compilando localmente + ```bash cargo build --release ``` @@ -22,6 +53,24 @@ O binário ficará em: target/release/restoredb ``` +## Publicação no Gitea + +Para empacotar o executável release e publicar no Generic Package Registry do Gitea: + +```bash +GITEA_TOKEN=seu_token scripts/publish-gitea-package.sh +``` + +Por padrão, o script publica `restoredb` na versão do `Cargo.toml` em `https://gitea.solucaoti.net.br/api/packages/rogeriolima/generic/restoredb//`. + +Opções úteis: + +```bash +scripts/publish-gitea-package.sh --dry-run +scripts/publish-gitea-package.sh --version 0.1.1 +scripts/publish-gitea-package.sh --no-build +``` + ## Configuração Na primeira execução, se nenhuma configuração completa existir, o programa pergunta os campos um a um, no estilo do `rclone`, e salva em `config.yml` na mesma pasta do executável. @@ -138,6 +187,8 @@ restoredb clean --older-than-days 30 Antes da restauração, a TUI sugere o banco de destino a partir do nome do dump, permite editar esse valor e então mostra host, banco de destino e dump selecionado, com aviso de que o banco será apagado. A restauração só ocorre após confirmação. +Durante download e restauração, CLI e TUI exibem barra de progresso. Se a restauração falhar com `ERROR: role "" does not exist`, o assistente cria a role PostgreSQL com `LOGIN`, recria o banco de destino e tenta restaurar o dump novamente uma vez. + ## Formatos suportados - `.sql`: restaurado com `psql` diff --git a/restoredb.log b/restoredb.log index dcb0351..5a52fb8 100644 --- a/restoredb.log +++ b/restoredb.log @@ -8,3 +8,31 @@ 2026-06-23T14:24:12.941459Z INFO restoredb::tui: Recriando banco de dados... 2026-06-23T14:24:13.090076Z INFO restoredb::tui: Restaurando dump... 2026-06-23T14:24:13.132939Z ERROR restoredb::tui: psql retornou erro: psql:dumps/matricula_20260623_045855.sql:47: ERROR: role "dsi" does not exist +2026-06-23T14:28:58.303056Z INFO restoredb::tui: Apagando banco de dados... +2026-06-23T14:28:58.538504Z INFO restoredb::tui: Recriando banco de dados... +2026-06-23T14:28:58.593070Z INFO restoredb::tui: Restaurando dump... +2026-06-23T14:29:01.597279Z INFO restoredb::tui: Restauração finalizada. +2026-06-23T14:29:18.910337Z INFO restoredb::minio: baixando dump remoto: selecaouab/selecaouab_20260623_050003.sql +2026-06-23T14:29:37.688056Z INFO restoredb::tui: Apagando banco de dados... +2026-06-23T14:29:37.722825Z INFO restoredb::tui: Recriando banco de dados... +2026-06-23T14:29:38.021376Z INFO restoredb::tui: Restaurando dump... +2026-06-23T14:29:57.025219Z INFO restoredb::tui: Restauração finalizada. +2026-06-23T14:30:23.679378Z INFO restoredb::minio: baixando dump remoto: selecaouab/selecaouab_20260623_050003.sql +2026-06-23T14:31:01.856318Z INFO restoredb::minio: baixando dump remoto: heteroidentificacao/heteroidentificacao_20260623_050003.sql +2026-06-23T14:31:09.446320Z INFO restoredb::tui: Apagando banco de dados... +2026-06-23T14:31:09.773361Z INFO restoredb::tui: Recriando banco de dados... +2026-06-23T14:31:09.874011Z INFO restoredb::tui: Restaurando dump... +2026-06-23T14:31:13.496503Z INFO restoredb::tui: Restauração finalizada. +2026-06-23T14:32:34.465550Z INFO restoredb::minio: baixando dump remoto: seletivos/seletivos_20260623_050003.sql +2026-06-23T14:33:22.550123Z INFO restoredb::tui: Apagando banco de dados... +2026-06-23T14:33:23.067803Z INFO restoredb::tui: Recriando banco de dados... +2026-06-23T14:33:23.183970Z INFO restoredb::tui: Restaurando dump... +2026-06-23T14:33:44.828478Z INFO restoredb::tui: Restauração finalizada. +2026-06-23T14:36:10.631441Z INFO restoredb::minio: baixando dump remoto: matricula/matricula_20260623_045855.sql +2026-06-23T14:36:20.736091Z INFO restoredb::tui: Apagando banco de dados... +2026-06-23T14:36:21.235459Z INFO restoredb::tui: Recriando banco de dados... +2026-06-23T14:36:21.347228Z INFO restoredb::tui: Restaurando dump... +2026-06-23T14:36:21.392003Z INFO restoredb::tui: Criando usuário PostgreSQL ausente: dsi... +2026-06-23T14:36:21.460034Z INFO restoredb::tui: Recriando banco de dados para nova tentativa... +2026-06-23T14:36:21.574834Z INFO restoredb::tui: Restaurando dump novamente... +2026-06-23T14:36:29.895035Z INFO restoredb::tui: Restauração finalizada. diff --git a/scripts/publish-gitea-package.sh b/scripts/publish-gitea-package.sh new file mode 100755 index 0000000..64d1c30 --- /dev/null +++ b/scripts/publish-gitea-package.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +GITEA_BASE_URL="${GITEA_BASE_URL:-https://gitea.solucaoti.net.br}" +GITEA_USER="${GITEA_USER:-rogeriolima}" +PACKAGE_OWNER="${PACKAGE_OWNER:-$GITEA_USER}" +PACKAGE_NAME="${PACKAGE_NAME:-restoredb}" +BINARY_NAME="${BINARY_NAME:-restoredb}" +BUILD=1 +DRY_RUN=0 +VERSION="" + +usage() { + cat <<'EOF' +Publica o executavel release no Generic Package Registry do Gitea. + +Uso: + GITEA_TOKEN=token scripts/publish-gitea-package.sh [opcoes] + +Opcoes: + --version VERSION Versao do pacote. Padrao: versao do Cargo.toml. + --no-build Nao executa cargo build --release antes de empacotar. + --dry-run Mostra os comandos principais sem enviar o pacote. + -h, --help Mostra esta ajuda. + +Variaveis: + GITEA_BASE_URL Padrao: https://gitea.solucaoti.net.br + GITEA_USER Padrao: rogeriolima + GITEA_TOKEN Token com permissao para publicar pacotes. + PACKAGE_OWNER Padrao: igual a GITEA_USER + PACKAGE_NAME Padrao: restoredb + BINARY_NAME Padrao: restoredb +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + if [[ -z "$VERSION" ]]; then + echo "Erro: --version exige um valor." >&2 + exit 2 + fi + shift 2 + ;; + --no-build) + BUILD=0 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Erro: opcao desconhecida: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Erro: comando obrigatorio nao encontrado: $1" >&2 + exit 1 + fi +} + +require_cmd cargo +require_cmd curl +require_cmd tar +require_cmd rustc + +if [[ -z "$VERSION" ]]; then + VERSION="$(cargo metadata --no-deps --format-version 1 | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -n 1)" +fi + +if [[ -z "$VERSION" ]]; then + echo "Erro: nao foi possivel descobrir a versao do Cargo.toml." >&2 + exit 1 +fi + +if [[ "$DRY_RUN" -eq 0 && -z "${GITEA_TOKEN:-}" ]]; then + echo "Erro: defina GITEA_TOKEN com um token do Gitea para publicar." >&2 + exit 1 +fi + +HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" +DIST_DIR="dist" +PACKAGE_FILE="${BINARY_NAME}-${HOST_TRIPLE}.tar.gz" +PACKAGE_PATH="${DIST_DIR}/${PACKAGE_FILE}" +UPLOAD_URL="${GITEA_BASE_URL%/}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${PACKAGE_FILE}" + +if [[ "$BUILD" -eq 1 ]]; then + echo "Compilando release..." + cargo build --release +fi + +if [[ ! -x "target/release/${BINARY_NAME}" ]]; then + echo "Erro: binario nao encontrado ou nao executavel: target/release/${BINARY_NAME}" >&2 + exit 1 +fi + +mkdir -p "$DIST_DIR" +tar -C target/release -czf "$PACKAGE_PATH" "$BINARY_NAME" + +echo "Pacote: $PACKAGE_PATH" +echo "URL: $UPLOAD_URL" + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry-run: upload nao executado." + exit 0 +fi + +curl --fail --show-error \ + -u "${GITEA_USER}:${GITEA_TOKEN}" \ + --upload-file "$PACKAGE_PATH" \ + "$UPLOAD_URL" + +echo +echo "Publicado: $UPLOAD_URL" diff --git a/src/dumps.rs b/src/dumps.rs index ccbb079..fd2160c 100644 --- a/src/dumps.rs +++ b/src/dumps.rs @@ -197,12 +197,16 @@ mod tests { } } -pub async fn materialize_dump( +pub async fn materialize_dump_with_progress( config: &Config, minio: &MinioClient, dump: &DumpEntry, overwrite: bool, -) -> Result { + progress: F, +) -> Result +where + F: FnMut(u64), +{ if let Some(path) = &dump.local_path { if path.exists() && !overwrite { return Ok(path.clone()); @@ -224,7 +228,7 @@ pub async fn materialize_dump( return Ok(destination); } minio - .download_dump(key, &destination, |_| {}) + .download_dump(key, &destination, progress) .await .with_context(|| format!("falha ao baixar {}", dump.name))?; Ok(destination) diff --git a/src/main.rs b/src/main.rs index 5c0231e..04a8518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use tracing::{error, info}; use crate::config::Config; use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name, suggest_database_name}; use crate::minio::MinioClient; -use crate::postgres_restore::{confirm_in_terminal, restore_dump}; +use crate::postgres_restore::{confirm_in_terminal, restore_dump, RestoreProgress}; #[derive(Parser, Debug)] #[command(name = "restoredb")] @@ -160,7 +160,16 @@ async fn command_restore( println!("Aviso: existe cópia local com tamanho diferente do remoto."); println!("A cópia local será usada. Apague o arquivo local para baixar novamente."); } - let local_path = dumps::materialize_dump(config, minio, &dump, false).await?; + let download_total = dump.size; + let mut downloaded = 0; + let local_path = dumps::materialize_dump_with_progress(config, minio, &dump, false, |bytes| { + downloaded = bytes; + render_byte_progress("Baixando dump", bytes, download_total); + }) + .await?; + if downloaded > 0 { + println!(); + } let target_database = prompt_target_database(&dump.name)?; let restore_config = config.with_postgres_database(target_database); @@ -192,11 +201,12 @@ async fn command_restore( &restore_config, &local_path, terminate_connections, - |message| { - println!("{message}"); + |progress| { + render_restore_progress(&progress); }, ) .await; + println!(); if let Err(err) = history::record_restore(&restore_config, &local_path, result.as_ref().err()).await { @@ -282,3 +292,40 @@ fn prompt_target_database(dump_name: &str) -> Result { println!("Banco de destino não pode ficar vazio."); } } + +fn render_restore_progress(progress: &RestoreProgress) { + render_progress( + &progress.message, + progress.current, + progress.total.max(progress.current), + ); +} + +fn render_byte_progress(label: &str, current: u64, total: Option) { + match total.filter(|total| *total > 0) { + Some(total) => render_progress(label, current.min(total), total), + None => { + print!("\r{label}: {}", bytesize::ByteSize::b(current)); + let _ = std::io::stdout().flush(); + } + } +} + +fn render_progress(label: &str, current: u64, total: u64) { + let width = 24usize; + let filled = if total == 0 { + width + } else { + ((current as f64 / total as f64) * width as f64).round() as usize + } + .min(width); + let bar = format!("{}{}", "#".repeat(filled), "-".repeat(width - filled)); + let percent = if total == 0 { + 100 + } else { + ((current as f64 / total as f64) * 100.0).round() as u64 + } + .min(100); + print!("\r[{bar}] {percent:>3}% {label}"); + let _ = std::io::stdout().flush(); +} diff --git a/src/minio.rs b/src/minio.rs index 8cfdb52..3f2b660 100644 --- a/src/minio.rs +++ b/src/minio.rs @@ -119,19 +119,25 @@ impl MinioClient { .await .with_context(|| format!("falha ao baixar objeto {key}"))?; - let bytes = collect_body(response.body).await?; if let Some(parent) = destination.parent() { tokio::fs::create_dir_all(parent).await?; } let mut file = File::create(destination).await?; - file.write_all(&bytes).await?; + write_body(response.body, &mut file, &mut progress).await?; file.flush().await?; - progress(bytes.len() as u64); Ok(()) } } -async fn collect_body(body: ByteStream) -> Result> { - let data = body.collect().await.context("falha ao ler stream S3")?; - Ok(data.into_bytes().to_vec()) +async fn write_body(mut body: ByteStream, file: &mut File, progress: &mut F) -> Result<()> +where + F: FnMut(u64), +{ + let mut downloaded = 0; + while let Some(bytes) = body.try_next().await.context("falha ao ler stream S3")? { + file.write_all(&bytes).await?; + downloaded += bytes.len() as u64; + progress(downloaded); + } + Ok(()) } diff --git a/src/postgres_restore.rs b/src/postgres_restore.rs index 76b9f2c..e9e9639 100644 --- a/src/postgres_restore.rs +++ b/src/postgres_restore.rs @@ -15,6 +15,13 @@ use tracing::debug; use crate::{config::Config, dumps::is_supported_dump, errors::RestoreAssistantError}; +#[derive(Debug, Clone)] +pub struct RestoreProgress { + pub current: u64, + pub total: u64, + pub message: String, +} + pub async fn check_tools() -> Result<()> { check_tool("psql").await?; check_tool("pg_restore").await?; @@ -79,7 +86,7 @@ pub async fn restore_dump( mut status: F, ) -> Result<()> where - F: FnMut(&str), + F: FnMut(RestoreProgress), { if !dump_path.exists() { bail!("arquivo não existe: {}", dump_path.display()); @@ -92,20 +99,71 @@ where return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into()); } + let mut current = 0; + let mut total = if terminate_connections { 5 } else { 4 }; + if terminate_connections { - status("Encerrando conexões ativas..."); + current += 1; + emit_progress(&mut status, current, total, "Encerrando conexões ativas..."); terminate_active_connections(config).await?; } - status("Apagando banco de dados..."); + current += 1; + emit_progress(&mut status, current, total, "Apagando banco de dados..."); drop_database(config).await?; - status("Recriando banco de dados..."); + current += 1; + emit_progress(&mut status, current, total, "Recriando banco de dados..."); create_database(config).await?; - status("Restaurando dump..."); - restore_contents(config, dump_path).await?; - status("Restauração finalizada."); + + current += 1; + emit_progress(&mut status, current, total, "Restaurando dump..."); + match restore_contents(config, dump_path).await { + Ok(()) => {} + Err(err) => { + let Some(role) = missing_role_name(&err.to_string()) else { + return Err(err); + }; + + total += 3; + current += 1; + emit_progress( + &mut status, + current, + total, + &format!("Criando usuário PostgreSQL ausente: {role}..."), + ); + create_role_if_missing(config, &role).await?; + + current += 1; + emit_progress( + &mut status, + current, + total, + "Recriando banco de dados para nova tentativa...", + ); + drop_database(config).await?; + create_database(config).await?; + + current += 1; + emit_progress(&mut status, current, total, "Restaurando dump novamente..."); + restore_contents(config, dump_path).await?; + } + } + + emit_progress(&mut status, total, total, "Restauração finalizada."); Ok(()) } +fn emit_progress(status: &mut F, current: u64, total: u64, message: &str) +where + F: FnMut(RestoreProgress), +{ + status(RestoreProgress { + current, + total, + message: message.to_string(), + }); +} + async fn terminate_active_connections(config: &Config) -> Result<()> { let sql = format!( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();", @@ -130,6 +188,24 @@ async fn create_database(config: &Config) -> Result<()> { run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &sql]).await } +async fn create_role_if_missing(config: &Config, role: &str) -> Result<()> { + let exists_sql = format!( + "SELECT 1 FROM pg_roles WHERE rolname = {};", + quote_literal(role) + ); + let exists = run_psql_admin_output( + config, + &["-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", &exists_sql], + ) + .await?; + if exists.trim() == "1" { + return Ok(()); + } + + let create_sql = format!("CREATE ROLE {} LOGIN;", quote_ident(role)); + run_psql_admin(config, &["-v", "ON_ERROR_STOP=1", "-c", &create_sql]).await +} + async fn restore_contents(config: &Config, dump_path: &Path) -> Result<()> { let name = dump_path .file_name() @@ -165,6 +241,7 @@ async fn restore_compressed_sql( let mut child = base_psql_command(config, &config.postgres_database) .args(["-v", "ON_ERROR_STOP=1"]) .stdin(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .context("falha ao iniciar psql para restaurar SQL comprimido")?; let stdin = child @@ -176,10 +253,14 @@ async fn restore_compressed_sql( tokio::spawn( async move { stream_compressed_sql_to_stdin(source, compression, stdin).await }, ); - let status = child.wait().await?; + let output = child.wait_with_output().await?; writer.await??; - if !status.success() { - bail!("psql retornou erro ao restaurar SQL comprimido"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "psql retornou erro ao restaurar SQL comprimido: {}", + stderr.trim() + ); } Ok(()) } @@ -270,7 +351,7 @@ fn decompress_pg_restore_archive_to_temp( } async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> { - let status = base_pg_command( + let output = base_pg_command( Command::new("pg_restore"), config, &config.postgres_database, @@ -278,11 +359,12 @@ async fn run_pg_restore(config: &Config, dump_path: &Path) -> Result<()> { .args(["--clean", "--if-exists", "--no-owner", "--dbname"]) .arg(connection_uri(config, &config.postgres_database)) .arg(dump_path) - .status() + .output() .await .context("falha ao executar pg_restore")?; - if !status.success() { - bail!("pg_restore retornou erro"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("pg_restore retornou erro: {}", stderr.trim()); } Ok(()) } @@ -381,3 +463,34 @@ fn url_encode(value: &str) -> String { } encoded } + +fn missing_role_name(message: &str) -> Option { + let marker = "role \""; + let start = message.find(marker)? + marker.len(); + let rest = &message[start..]; + let end = rest.find("\" does not exist")?; + let role = &rest[..end]; + if role.is_empty() { + None + } else { + Some(role.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::missing_role_name; + + #[test] + fn extracts_missing_role_name_from_postgres_error() { + assert_eq!( + missing_role_name("ERROR: role \"dsi\" does not exist"), + Some("dsi".to_string()) + ); + assert_eq!( + missing_role_name("psql:file.sql:47: ERROR: role \"app_user\" does not exist"), + Some("app_user".to_string()) + ); + assert_eq!(missing_role_name("ERROR: database does not exist"), None); + } +} diff --git a/src/tui.rs b/src/tui.rs index 6cb4151..c955d84 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -8,8 +8,9 @@ use crossterm::{ }; use ratatui::{ prelude::*, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap}, }; +use tokio::sync::mpsc::{self, UnboundedReceiver}; use tracing::{error, info}; use crate::{ @@ -38,10 +39,27 @@ enum Mode { count: i64, }, ConfirmDelete(DumpEntry), - Working, + Working { + current: u64, + total: u64, + message: String, + }, Error(String), } +enum WorkerEvent { + Progress { + current: u64, + total: u64, + message: String, + }, + DumpReady { + path: PathBuf, + dump_name: String, + }, + Finished(Result<(), String>), +} + struct App { config: Config, minio: MinioClient, @@ -51,6 +69,7 @@ struct App { filter: String, status: String, mode: Mode, + worker_rx: Option>, } pub async fn run(config: Config, minio: MinioClient) -> Result<()> { @@ -72,6 +91,7 @@ impl App { filter: String::new(), status: "Carregando dumps...".to_string(), mode: Mode::Browsing, + worker_rx: None, }; app.refresh().await; Ok(app) @@ -79,6 +99,7 @@ impl App { async fn run(&mut self, terminal: &mut Terminal>) -> Result<()> { loop { + self.poll_worker_events(); terminal.draw(|frame| self.draw(frame))?; if !event::poll(Duration::from_millis(120))? { continue; @@ -181,7 +202,7 @@ impl App { } _ => {} }, - Mode::Working => {} + Mode::Working { .. } => {} Mode::Error(_) => match key.code { KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Browsing, KeyCode::Char('q') => break, @@ -248,18 +269,39 @@ impl App { "Usando cópia local existente. Remova o arquivo para baixar novamente.".to_string() }; } - match dumps::materialize_dump(&self.config, &self.minio, &dump, false).await { - Ok(path) => { - self.mode = Mode::EditingDatabase { - path, - input: suggest_database_name(&dump.name), - }; + let config = self.config.clone(); + let minio = self.minio.clone(); + let total = dump.size.unwrap_or(1); + let (tx, rx) = mpsc::unbounded_channel(); + self.worker_rx = Some(rx); + self.mode = Mode::Working { + current: 0, + total, + message: "Preparando dump...".to_string(), + }; + tokio::spawn(async move { + let dump_name = dump.name.clone(); + let result = + dumps::materialize_dump_with_progress(&config, &minio, &dump, false, |bytes| { + let _ = tx.send(WorkerEvent::Progress { + current: bytes, + total: total.max(bytes).max(1), + message: "Baixando dump...".to_string(), + }); + }) + .await; + match result { + Ok(path) => { + let _ = tx.send(WorkerEvent::DumpReady { path, dump_name }); + } + Err(err) => { + error!("{err:?}"); + let _ = tx.send(WorkerEvent::Finished(Err(format!( + "Falha ao preparar dump: {err}" + )))); + } } - Err(err) => { - error!("{err:?}"); - self.mode = Mode::Error(format!("Falha ao preparar dump: {err}")); - } - } + }); } fn prepare_delete(&mut self) { @@ -307,32 +349,79 @@ impl App { } async fn restore(&mut self, path: PathBuf, database: String, terminate_connections: bool) { - self.mode = Mode::Working; - self.status = "Iniciando restauração...".to_string(); let restore_config = self.config.with_postgres_database(database); - let result = postgres_restore::restore_dump( - &restore_config, - &path, - terminate_connections, - |message| { - info!("{message}"); - }, - ) - .await; - if let Err(err) = - history::record_restore(&restore_config, &path, result.as_ref().err()).await - { - error!("{err:?}"); - } - match result { - Ok(()) => { - self.status = "Restauração concluída.".to_string(); - self.mode = Mode::Browsing; - } - Err(err) => { + let (tx, rx) = mpsc::unbounded_channel(); + self.worker_rx = Some(rx); + self.mode = Mode::Working { + current: 0, + total: if terminate_connections { 5 } else { 4 }, + message: "Iniciando restauração...".to_string(), + }; + tokio::spawn(async move { + let result = postgres_restore::restore_dump( + &restore_config, + &path, + terminate_connections, + |progress| { + info!("{}", progress.message); + let _ = tx.send(WorkerEvent::Progress { + current: progress.current, + total: progress.total.max(progress.current).max(1), + message: progress.message, + }); + }, + ) + .await; + if let Err(err) = + history::record_restore(&restore_config, &path, result.as_ref().err()).await + { error!("{err:?}"); - self.mode = Mode::Error(format!("Falha na restauração: {err}")); } + let _ = tx.send(WorkerEvent::Finished( + result.map_err(|err| format!("Falha na restauração: {err}")), + )); + }); + } + + fn poll_worker_events(&mut self) { + let mut clear_worker = false; + if let Some(rx) = &mut self.worker_rx { + while let Ok(event) = rx.try_recv() { + match event { + WorkerEvent::Progress { + current, + total, + message, + } => { + self.status = message.clone(); + self.mode = Mode::Working { + current, + total, + message, + }; + } + WorkerEvent::DumpReady { path, dump_name } => { + self.status = "Dump preparado.".to_string(); + self.mode = Mode::EditingDatabase { + path, + input: suggest_database_name(&dump_name), + }; + clear_worker = true; + } + WorkerEvent::Finished(Ok(())) => { + self.status = "Restauração concluída.".to_string(); + self.mode = Mode::Browsing; + clear_worker = true; + } + WorkerEvent::Finished(Err(message)) => { + self.mode = Mode::Error(message); + clear_worker = true; + } + } + } + } + if clear_worker { + self.worker_rx = None; } } @@ -346,7 +435,7 @@ impl App { .split(area); let title = format!( - "restoredb | PostgreSQL: {}:{}", + "restoredb | PostgreSQL: {}:{} | Usar apenas em ambiente de desenvolvimento - ®IFPI-DTI-DSI", self.config.postgres_host, self.config.postgres_port ); frame.render_widget( @@ -415,12 +504,11 @@ impl App { count, } => self.draw_terminate_confirm(frame, area, path, database, *count), Mode::ConfirmDelete(dump) => self.draw_delete_confirm(frame, area, dump), - Mode::Working => self.draw_popup( - frame, - area, - "Restaurando", - "Aguarde. O banco está sendo apagado, recriado e restaurado.", - ), + Mode::Working { + current, + total, + message, + } => self.draw_progress(frame, area, *current, *total, message), Mode::Error(message) => self.draw_popup(frame, area, "Erro", message), _ => {} } @@ -435,6 +523,34 @@ impl App { self.draw_popup(frame, area, "Banco de destino", &text); } + fn draw_progress( + &self, + frame: &mut Frame, + area: Rect, + current: u64, + total: u64, + message: &str, + ) { + let popup = centered_rect(70, 25, area); + let percent = if total == 0 { + 1.0 + } else { + (current as f64 / total as f64).clamp(0.0, 1.0) + }; + frame.render_widget(Clear, popup); + frame.render_widget( + Gauge::default() + .block( + Block::default() + .title(message.to_string()) + .borders(Borders::ALL), + ) + .gauge_style(Style::default().fg(Color::Green)) + .ratio(percent), + popup, + ); + } + fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf, database: &str) { let text = format!( "Esta operação apagará e recriará o banco.\n\nHost: {}\nBanco: {}\nDump: {}\n\nPressione y/s para confirmar ou Esc/n para cancelar.",