Atualiza fluxo de restauracao de dumps

This commit is contained in:
Rogério de Lima Sousa 2026-06-23 11:23:28 -03:00
parent ab37755e77
commit 2f4c25979d
11 changed files with 358 additions and 98 deletions

View File

@ -1,15 +0,0 @@
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=backups
MINIO_REGION=us-east-1
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DATABASE=app_db
POSTGRES_SSLMODE=prefer
DUMPS_DIR=./dumps

56
Cargo.lock generated
View File

@ -2083,6 +2083,34 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "restoredb"
version = "0.1.0"
dependencies = [
"anyhow",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"bytesize",
"chrono",
"clap",
"crossterm",
"dotenvy",
"flate2",
"futures-util",
"ratatui",
"rpassword",
"serde",
"serde_yaml",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zstd",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@ -2118,34 +2146,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rsdb"
version = "0.1.0"
dependencies = [
"anyhow",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"bytesize",
"chrono",
"clap",
"crossterm",
"dotenvy",
"flate2",
"futures-util",
"ratatui",
"rpassword",
"serde",
"serde_yaml",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
"zstd",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"

View File

@ -1,5 +1,5 @@
[package]
name = "rsdb"
name = "restoredb"
version = "0.1.0"
edition = "2021"

View File

@ -1,4 +1,4 @@
# rsdb
# restoredb
Assistente CLI/TUI em Rust para listar dumps em MinIO/S3, baixar arquivos para uma pasta local e restaurar um banco PostgreSQL com `DROP DATABASE` e recriação controlados por confirmação explícita.
@ -19,7 +19,7 @@ cargo build --release
O binário ficará em:
```bash
target/release/rsdb
target/release/restoredb
```
## Configuração
@ -51,7 +51,7 @@ cp config.example.toml config.toml
Exemplo `config.yml`:
```bash
cp config.example.yml "$(dirname "$(realpath target/release/rsdb)")/config.yml"
cp config.example.yml "$(dirname "$(realpath target/release/restoredb)")/config.yml"
```
Variáveis obrigatórias:
@ -79,51 +79,51 @@ Senhas nunca são exibidas. A senha do PostgreSQL é passada para `psql` e `pg_r
Abrir a TUI:
```bash
rsdb
restoredb
```
Listar dumps remotos e locais:
```bash
rsdb list
restoredb list
```
Restaurar diretamente um dump local ou remoto, ainda com confirmação:
```bash
rsdb restore backup.sql.gz
restoredb restore backup.sql.gz
```
Simular uma restauração sem baixar, apagar ou restaurar:
```bash
rsdb restore backup.sql.gz --dry-run
restoredb restore backup.sql.gz --dry-run
```
Validar configuração, pasta local, conexão S3 e ferramentas PostgreSQL:
```bash
rsdb doctor
restoredb doctor
```
`rsdb config-check` continua disponível como comando compatível.
`restoredb config-check` continua disponível como comando compatível.
Mostrar a configuração carregada, com senhas mascaradas:
```bash
rsdb config show
restoredb config show
```
Apagar dumps locais, após confirmação:
```bash
rsdb clean
restoredb clean
```
Apagar apenas dumps locais mais antigos que 30 dias:
```bash
rsdb clean --older-than-days 30
restoredb clean --older-than-days 30
```
## Controles da TUI
@ -136,7 +136,7 @@ rsdb clean --older-than-days 30
- `Enter`: selecionar/restaurar
- `q`: sair
Antes da restauração, a TUI mostra o host, banco de destino e dump selecionado, com aviso de que o banco será apagado. A restauração só ocorre após confirmação.
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.
## Formatos suportados
@ -150,7 +150,7 @@ Antes da restauração, a TUI mostra o host, banco de destino e dump selecionado
Logs técnicos são gravados em:
```bash
rsdb.log
restoredb.log
```
O histórico de restaurações é salvo em:

10
restoredb.log Normal file
View File

@ -0,0 +1,10 @@
2026-06-23T14:19:02.498663Z INFO restoredb::minio: baixando dump remoto: heteroidentificacao/heteroidentificacao_20260623_050003.sql
2026-06-23T14:23:42.294012Z INFO restoredb::tui: Apagando banco de dados...
2026-06-23T14:23:42.434218Z INFO restoredb::tui: Recriando banco de dados...
2026-06-23T14:23:42.746784Z INFO restoredb::tui: Restaurando dump...
2026-06-23T14:23:46.010945Z INFO restoredb::tui: Restauração finalizada.
2026-06-23T14:23:52.728367Z INFO restoredb::minio: baixando dump remoto: matricula/matricula_20260623_045855.sql
2026-06-23T14:24:12.411620Z INFO restoredb::tui: Apagando banco de dados...
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

View File

@ -52,7 +52,7 @@ impl Config {
&& !PathBuf::from("config.toml").exists()
&& !has_required_env()
{
println!("Configuração inicial do rsdb");
println!("Configuração inicial do restoredb");
println!(
"As respostas serão salvas em {}",
executable_yaml_path.display()
@ -138,6 +138,12 @@ impl Config {
};
serde_yaml::to_string(&masked).context("falha ao gerar YAML da configuração")
}
pub fn with_postgres_database(&self, postgres_database: String) -> Self {
let mut config = self.clone();
config.postgres_database = postgres_database;
config
}
}
fn merge_file_config(base: FileConfig, override_with: FileConfig) -> FileConfig {

View File

@ -137,6 +137,41 @@ pub fn is_supported_dump(name: &str) -> bool {
SUPPORTED_EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
}
pub fn suggest_database_name(name: &str) -> String {
let basename = Path::new(name)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(name);
let without_extension = strip_supported_extension(basename).unwrap_or(basename);
strip_timestamp_suffix(without_extension)
.unwrap_or(without_extension)
.to_string()
}
fn strip_supported_extension(name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
SUPPORTED_EXTENSIONS
.iter()
.filter(|ext| lower.ends_with(**ext))
.max_by_key(|ext| ext.len())
.map(|ext| &name[..name.len() - ext.len()])
}
fn strip_timestamp_suffix(name: &str) -> Option<&str> {
let (prefix, time) = name.rsplit_once('_')?;
let (prefix, date) = prefix.rsplit_once('_')?;
if date.len() == 8
&& time.len() == 6
&& date.bytes().all(|byte| byte.is_ascii_digit())
&& time.bytes().all(|byte| byte.is_ascii_digit())
&& !prefix.is_empty()
{
Some(prefix)
} else {
None
}
}
pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> {
dumps
.iter()
@ -144,6 +179,24 @@ pub fn find_dump_by_name(dumps: &[DumpEntry], name: &str) -> Option<DumpEntry> {
.cloned()
}
#[cfg(test)]
mod tests {
use super::suggest_database_name;
#[test]
fn suggests_database_name_from_common_dump_names() {
assert_eq!(
suggest_database_name("heteroidentificacao_20260623_050003.sql"),
"heteroidentificacao"
);
assert_eq!(
suggest_database_name("backup/minha_base_20260623_050003.dump.gz"),
"minha_base"
);
assert_eq!(suggest_database_name("app_db.sql.zst"), "app_db");
}
}
pub async fn materialize_dump(
config: &Config,
minio: &MinioClient,

View File

@ -6,17 +6,19 @@ mod minio;
mod postgres_restore;
mod tui;
use std::io::Write;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tracing::{error, info};
use crate::config::Config;
use crate::dumps::{collect_dumps, ensure_dumps_dir, find_dump_by_name};
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};
#[derive(Parser, Debug)]
#[command(name = "rsdb")]
#[command(name = "restoredb")]
#[command(about = "Assistente para restaurar PostgreSQL a partir de dumps MinIO/S3")]
struct Cli {
#[command(subcommand)]
@ -56,6 +58,9 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
let config = Config::load().context("falha ao carregar configuração")?;
ensure_dumps_dir(&config.dumps_dir)?;
postgres_restore::check_tools()
.await
.context("ferramentas necessárias indisponíveis")?;
let minio = MinioClient::new(&config).await?;
let result = match cli.command {
@ -83,14 +88,14 @@ async fn main() -> Result<()> {
}
fn init_logging() -> Result<tracing_appender::non_blocking::WorkerGuard> {
let file_appender = tracing_appender::rolling::never(".", "rsdb.log");
let file_appender = tracing_appender::rolling::never(".", "restoredb.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_ansi(false)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rsdb=info".into()),
.unwrap_or_else(|_| "restoredb=info".into()),
)
.init();
Ok(guard)
@ -129,6 +134,7 @@ async fn command_restore(
let dump =
find_dump_by_name(&dumps, name).with_context(|| format!("dump não encontrado: {name}"))?;
if dry_run {
let suggested_database = suggest_database_name(&dump.name);
println!("Dry-run: nenhuma alteração será executada.");
println!("Dump: {}", dump.name);
println!("Origem: {}", dump.kind.label());
@ -141,7 +147,7 @@ async fn command_restore(
);
println!(
"Destino: {}:{} / {}",
config.postgres_host, config.postgres_port, config.postgres_database
config.postgres_host, config.postgres_port, suggested_database
);
if dump.local_remote_size_mismatch() {
println!("Aviso: o tamanho local difere do tamanho remoto.");
@ -155,17 +161,45 @@ async fn command_restore(
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 target_database = prompt_target_database(&dump.name)?;
let restore_config = config.with_postgres_database(target_database);
if !confirm_in_terminal(config, &local_path)? {
if !confirm_in_terminal(&restore_config, &local_path)? {
println!("Operação cancelada.");
return Ok(());
}
let result = restore_dump(config, &local_path, |message| {
println!("{message}");
})
let active_connections = postgres_restore::count_active_connections(&restore_config).await?;
let terminate_connections = if active_connections > 0 {
print!(
"Existem {active_connections} conexão(ões) ativa(s) no banco \"{}\". Encerrar para continuar? (s/N): ",
restore_config.postgres_database
);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("s") {
true
} else {
println!("Operação cancelada.");
return Ok(());
}
} else {
false
};
let result = restore_dump(
&restore_config,
&local_path,
terminate_connections,
|message| {
println!("{message}");
},
)
.await;
if let Err(err) = history::record_restore(config, &local_path, result.as_ref().err()).await {
if let Err(err) =
history::record_restore(&restore_config, &local_path, result.as_ref().err()).await
{
error!("{err:?}");
}
result?;
@ -229,3 +263,22 @@ fn confirm_text(prompt: &str, expected: &str) -> Result<bool> {
std::io::stdin().read_line(&mut input)?;
Ok(input.trim() == expected)
}
fn prompt_target_database(dump_name: &str) -> Result<String> {
let suggested = suggest_database_name(dump_name);
loop {
print!("Banco de destino [{suggested}]: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let database = if input.trim().is_empty() {
suggested.clone()
} else {
input.trim().to_string()
};
if !database.is_empty() {
return Ok(database);
}
println!("Banco de destino não pode ficar vazio.");
}
}

View File

@ -26,7 +26,7 @@ impl MinioClient {
config.minio_secret_key.clone(),
None,
None,
"rsdb",
"restoredb",
);
let shared_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(config.minio_endpoint.clone())

View File

@ -26,17 +26,27 @@ pub async fn check_admin_connection(config: &Config) -> Result<()> {
}
async fn check_tool(name: &str) -> Result<()> {
let output = Command::new(name)
.arg("--version")
.output()
.await
.with_context(|| format!("{name} não encontrado no PATH"))?;
if !output.status.success() {
bail!("{name} retornou erro em --version");
let result = Command::new(name).arg("--version").output().await;
let ok = matches!(result, Ok(output) if output.status.success());
if !ok {
bail!(
"{name} não encontrado no PATH ou não pôde ser executado. {}",
install_hint()
);
}
Ok(())
}
fn install_hint() -> String {
if cfg!(target_os = "macos") {
"Instale com: brew install libpq && brew link --force libpq".to_string()
} else if cfg!(target_os = "windows") {
"Instale o PostgreSQL (inclui psql e pg_restore) em https://www.postgresql.org/download/windows/ e adicione o diretório bin ao PATH".to_string()
} else {
"Instale o pacote cliente do PostgreSQL: sudo apt install postgresql-client (Debian/Ubuntu) ou sudo dnf install postgresql (Fedora/RHEL)".to_string()
}
}
pub fn confirm_in_terminal(config: &Config, dump: &Path) -> Result<bool> {
println!("ATENÇÃO: esta operação apagará e recriará o banco de dados.");
println!("Host: {}", config.postgres_host);
@ -49,7 +59,25 @@ pub fn confirm_in_terminal(config: &Config, dump: &Path) -> Result<bool> {
Ok(input.trim() == "RESTAURAR")
}
pub async fn restore_dump<F>(config: &Config, dump_path: &Path, mut status: F) -> Result<()>
pub async fn count_active_connections(config: &Config) -> Result<i64> {
let sql = format!(
"SELECT count(*) FROM pg_stat_activity WHERE datname = {} AND pid <> pg_backend_pid();",
quote_literal(&config.postgres_database)
);
let output =
run_psql_admin_output(config, &["-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", &sql]).await?;
output
.trim()
.parse::<i64>()
.context("falha ao interpretar contagem de conexões ativas")
}
pub async fn restore_dump<F>(
config: &Config,
dump_path: &Path,
terminate_connections: bool,
mut status: F,
) -> Result<()>
where
F: FnMut(&str),
{
@ -64,8 +92,10 @@ where
return Err(RestoreAssistantError::UnsupportedDumpFormat(name.to_string()).into());
}
status("Encerrando conexões ativas...");
terminate_connections(config).await?;
if terminate_connections {
status("Encerrando conexões ativas...");
terminate_active_connections(config).await?;
}
status("Apagando banco de dados...");
drop_database(config).await?;
status("Recriando banco de dados...");
@ -76,7 +106,7 @@ where
Ok(())
}
async fn terminate_connections(config: &Config) -> Result<()> {
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();",
quote_literal(&config.postgres_database)
@ -218,7 +248,7 @@ fn decompress_pg_restore_archive_to_temp(
let file = std::fs::File::open(source)
.with_context(|| format!("falha ao abrir {}", source.display()))?;
let temp_path = std::env::temp_dir().join(format!(
"rsdb-{}-{}.tar",
"restoredb-{}-{}.tar",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
@ -275,6 +305,10 @@ async fn run_psql_admin(config: &Config, args: &[&str]) -> Result<()> {
run_command(base_psql_command(config, "postgres").args(args), "psql").await
}
async fn run_psql_admin_output(config: &Config, args: &[&str]) -> Result<String> {
run_command_output(base_psql_command(config, "postgres").args(args), "psql").await
}
async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> {
run_command(
base_psql_command(config, &config.postgres_database).args(args),
@ -284,6 +318,10 @@ async fn run_psql_target(config: &Config, args: &[&str]) -> Result<()> {
}
async fn run_command(command: &mut Command, label: &str) -> Result<()> {
run_command_output(command, label).await.map(|_| ())
}
async fn run_command_output(command: &mut Command, label: &str) -> Result<String> {
debug!("executando comando externo: {label}");
let output = command
.output()
@ -293,7 +331,7 @@ async fn run_command(command: &mut Command, label: &str) -> Result<()> {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{label} retornou erro: {}", stderr.trim());
}
Ok(())
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn base_psql_command(config: &Config, database: &str) -> Command {

View File

@ -14,7 +14,7 @@ use tracing::{error, info};
use crate::{
config::Config,
dumps::{self, DumpEntry, DumpKind},
dumps::{self, suggest_database_name, DumpEntry, DumpKind},
errors::RestoreAssistantError,
history,
minio::MinioClient,
@ -24,7 +24,19 @@ use crate::{
enum Mode {
Browsing,
Filtering,
Confirming(PathBuf),
EditingDatabase {
path: PathBuf,
input: String,
},
Confirming {
path: PathBuf,
database: String,
},
ConfirmTerminate {
path: PathBuf,
database: String,
count: i64,
},
ConfirmDelete(DumpEntry),
Working,
Error(String),
@ -105,10 +117,39 @@ impl App {
}
_ => {}
},
Mode::Confirming(path) => match key.code {
Mode::EditingDatabase { .. } => match key.code {
KeyCode::Enter => {
if let Mode::EditingDatabase { path, input } = &self.mode {
let path = path.clone();
let database = input.trim().to_string();
if database.is_empty() {
self.status = "Banco de destino não pode ficar vazio.".to_string();
} else {
self.mode = Mode::Confirming { path, database };
}
}
}
KeyCode::Backspace => {
if let Mode::EditingDatabase { input, .. } = &mut self.mode {
input.pop();
}
}
KeyCode::Char(c) => {
if let Mode::EditingDatabase { input, .. } = &mut self.mode {
input.push(c);
}
}
KeyCode::Esc => {
self.status = "Restauração cancelada.".to_string();
self.mode = Mode::Browsing;
}
_ => {}
},
Mode::Confirming { path, database } => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => {
let path = path.clone();
self.restore(path).await;
let database = database.clone();
self.check_connections(path, database).await;
}
KeyCode::Esc | KeyCode::Char('n') => {
self.status = "Restauração cancelada.".to_string();
@ -116,6 +157,19 @@ impl App {
}
_ => {}
},
Mode::ConfirmTerminate { path, database, .. } => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => {
let path = path.clone();
let database = database.clone();
self.restore(path, database, true).await;
}
KeyCode::Esc | KeyCode::Char('n') => {
self.status =
"Restauração cancelada: conexões ativas não encerradas.".to_string();
self.mode = Mode::Browsing;
}
_ => {}
},
Mode::ConfirmDelete(dump) => match key.code {
KeyCode::Char('y') | KeyCode::Char('s') => {
let dump = dump.clone();
@ -196,7 +250,10 @@ impl App {
}
match dumps::materialize_dump(&self.config, &self.minio, &dump, false).await {
Ok(path) => {
self.mode = Mode::Confirming(path);
self.mode = Mode::EditingDatabase {
path,
input: suggest_database_name(&dump.name),
};
}
Err(err) => {
error!("{err:?}");
@ -231,14 +288,39 @@ impl App {
}
}
async fn restore(&mut self, path: PathBuf) {
async fn check_connections(&mut self, path: PathBuf, database: String) {
let restore_config = self.config.with_postgres_database(database.clone());
match postgres_restore::count_active_connections(&restore_config).await {
Ok(count) if count > 0 => {
self.mode = Mode::ConfirmTerminate {
path,
database,
count,
};
}
Ok(_) => self.restore(path, database, false).await,
Err(err) => {
error!("{err:?}");
self.mode = Mode::Error(format!("Falha ao verificar conexões ativas: {err}"));
}
}
}
async fn restore(&mut self, path: PathBuf, database: String, terminate_connections: bool) {
self.mode = Mode::Working;
self.status = "Iniciando restauração...".to_string();
let result = postgres_restore::restore_dump(&self.config, &path, |message| {
info!("{message}");
})
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(&self.config, &path, result.as_ref().err()).await
if let Err(err) =
history::record_restore(&restore_config, &path, result.as_ref().err()).await
{
error!("{err:?}");
}
@ -264,8 +346,8 @@ impl App {
.split(area);
let title = format!(
"rsdb | destino: {}:{} / {}",
self.config.postgres_host, self.config.postgres_port, self.config.postgres_database
"restoredb | PostgreSQL: {}:{}",
self.config.postgres_host, self.config.postgres_port
);
frame.render_widget(
Paragraph::new(title).block(Block::default().borders(Borders::ALL)),
@ -323,7 +405,15 @@ impl App {
);
match &self.mode {
Mode::Confirming(path) => self.draw_confirm(frame, area, path),
Mode::EditingDatabase { path, input } => {
self.draw_database_input(frame, area, path, input)
}
Mode::Confirming { path, database } => self.draw_confirm(frame, area, path, database),
Mode::ConfirmTerminate {
path,
database,
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,
@ -336,16 +426,41 @@ impl App {
}
}
fn draw_confirm(&self, frame: &mut Frame, area: Rect, path: &PathBuf) {
fn draw_database_input(&self, frame: &mut Frame, area: Rect, path: &PathBuf, input: &str) {
let text = format!(
"Informe o banco de destino.\n\nDump: {}\nBanco: {}\n\nEnter confirma, Esc cancela.",
path.display(),
input
);
self.draw_popup(frame, area, "Banco de destino", &text);
}
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.",
self.config.postgres_host,
self.config.postgres_database,
database,
path.display()
);
self.draw_popup(frame, area, "Confirmar restauração", &text);
}
fn draw_terminate_confirm(
&self,
frame: &mut Frame,
area: Rect,
path: &PathBuf,
database: &str,
count: i64,
) {
let text = format!(
"Existem {count} conexão(ões) ativa(s) no banco \"{}\".\nDump: {}\n\nEncerrar as conexões para continuar?\n\nPressione y/s para confirmar ou Esc/n para cancelar.",
database,
path.display()
);
self.draw_popup(frame, area, "Conexões ativas", &text);
}
fn draw_delete_confirm(&self, frame: &mut Frame, area: Rect, dump: &DumpEntry) {
let path = dump
.local_path