feat: add read-only PostgreSQL inspection TUI

This commit is contained in:
Valdemar Lorenzen
2026-10-09 17:01:53 +02:00
commit c73fa18bf3
6 changed files with 2392 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
mod app;
mod db;
use std::{error::Error, io::IsTerminal, process::ExitCode};
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const HELP: &str = r#"dbdash — read-only PostgreSQL inspection
Usage:
dbdash [POSTGRES_CONNECTION_STRING]
dbdash --help
With no argument, the connection is read from DATABASE_URL.
Prefer the environment variable to keep passwords out of process arguments:
export DATABASE_URL='postgresql://user@localhost:5432/database'
cargo run
PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections.
The terminal must be at least 70 columns wide and 12 lines high.
Navigation:
Tab / Esc Switch focus / return to relations
Up/Down, j/k Move selection (Enter opens a relation)
Home/End, g/G First / last item
s Toggle rows and column schema
Left/Right Scroll columns (h/l also work)
n/p, PgDn/Up Next / previous 100-row page
r Refresh relations and the active relation
q / Ctrl-C Quit
Reads are enforced read-only by PostgreSQL and have a 5-second query timeout.
The interface waits for reads to complete. NULL cells are dimmed.
Pages use primary-key order, or physical order for keyless tables.
Keyless views have no guaranteed order; concurrent changes can shift pages.
"#;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("dbdash: {}", error_text(error.as_ref()));
ExitCode::FAILURE
}
}
}
fn run() -> Result<()> {
let mut args = std::env::args_os().skip(1);
let connection = match args.next() {
Some(argument) if argument == "--help" || argument == "-h" => {
print!("{HELP}");
return Ok(());
}
Some(argument) => argument
.into_string()
.map_err(|_| "connection string must be UTF-8")?,
None => std::env::var("DATABASE_URL")
.map_err(|_| "set DATABASE_URL or pass a connection string; see dbdash --help")?,
};
if args.next().is_some() {
return Err("expected one connection string; see dbdash --help".into());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err("an interactive terminal is required; see dbdash --help".into());
}
let mut app = app::App::new(db::Database::connect(&connection)?)?;
let result = (|| {
let mut terminal = ratatui::try_init()?;
app.run(&mut terminal)
})();
ratatui::restore();
result
}
fn error_text(error: &(dyn Error + 'static)) -> String {
let mut text = error.to_string();
let mut source = error.source();
while let Some(error) = source {
text.push_str(": ");
text.push_str(&error.to_string());
source = error.source();
}
db::display_text(&text)
}