feat(ui): Add SQL debug console in debug page
This commit is contained in:
parent
c881bf858d
commit
964cfa61b3
3 changed files with 349 additions and 0 deletions
|
|
@ -1,11 +1,16 @@
|
|||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_core::LauncherState;
|
||||
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
|
||||
|
||||
use crate::components::{Button, Icon, IconType, TextInput, toggle};
|
||||
use crate::hooks::use_dispatch;
|
||||
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
type SqlResult = Option<Result<ConsoleQueryResult, String>>;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct Debug;
|
||||
|
|
@ -63,6 +68,8 @@ impl Component for Debug {
|
|||
vec![ClusterUpdateSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section("SQL Console", vec![SqlConsole.into_element()]))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Other",
|
||||
vec![action_row(vec![
|
||||
|
|
@ -254,6 +261,170 @@ impl Component for ClusterUpdateSimulator {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SqlConsole;
|
||||
|
||||
impl Component for SqlConsole {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let query = use_state(|| "SELECT * FROM clusters;".to_string());
|
||||
let result = use_state(|| None::<Result<ConsoleQueryResult, String>>);
|
||||
let running = use_state(|| false);
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
rect().width(Size::flex(1.0)).child(
|
||||
TextInput::new(query)
|
||||
.placeholder("SELECT * FROM …")
|
||||
.on_submit(move |_| run_sql(query, result, running)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.child(Icon::new(IconType::Terminal).size(16.))
|
||||
.text(if *running.read() { "Running…" } else { "Run" })
|
||||
.on_press(move |_| run_sql(query, result, running)),
|
||||
),
|
||||
)
|
||||
.child(sql_result(&result.read()))
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn run_sql(query: State<String>, mut result: State<SqlResult>, mut running: State<bool>) {
|
||||
if *running.read() {
|
||||
return;
|
||||
}
|
||||
let sql = query.read().clone();
|
||||
if sql.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
running.set(true);
|
||||
spawn(async move {
|
||||
let res = match LauncherState::get() {
|
||||
Ok(state) => run_console_query(&state.services.db, &sql)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
result.set(Some(res));
|
||||
running.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
fn sql_result(state: &SqlResult) -> Element {
|
||||
match state {
|
||||
None => label()
|
||||
.text("No query run yet.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element(),
|
||||
Some(Err(err)) => rect()
|
||||
.width(Size::fill())
|
||||
.padding(Gaps::new_symmetric(10., 14.))
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.background(colors::component_bg())
|
||||
.border(border_all_color(1., colors::danger()))
|
||||
.child(
|
||||
label()
|
||||
.text(err.clone())
|
||||
.font_size(13.)
|
||||
.color(colors::danger()),
|
||||
)
|
||||
.into_element(),
|
||||
Some(Ok(res)) if !res.is_select => label()
|
||||
.text(format!("OK — {} row(s) affected.", res.rows_affected))
|
||||
.font_size(13.)
|
||||
.color(colors::success())
|
||||
.into_element(),
|
||||
Some(Ok(res)) => sql_table(res),
|
||||
}
|
||||
}
|
||||
|
||||
fn sql_table(res: &ConsoleQueryResult) -> Element {
|
||||
if res.columns.is_empty() {
|
||||
return label()
|
||||
.text("0 rows returned.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element();
|
||||
}
|
||||
|
||||
let header = table_row(&res.columns, true);
|
||||
let mut table = rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.corner_radius(CornerRadius::new_all(8.))
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.child(header);
|
||||
|
||||
for row in &res.rows {
|
||||
table = table.child(table_row(row, false));
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(6.)
|
||||
.child(
|
||||
label()
|
||||
.text(format!("{} row(s)", res.rows.len()))
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(table)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
fn table_row(cells: &[String], header: bool) -> Element {
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.background(if header {
|
||||
colors::page_elevated()
|
||||
} else {
|
||||
colors::component_bg()
|
||||
});
|
||||
|
||||
for cell in cells {
|
||||
row = row.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.padding(Gaps::new_symmetric(6., 10.))
|
||||
.border(border_all_color(0.5, colors::component_border()))
|
||||
.child(
|
||||
label()
|
||||
.text(cell.clone())
|
||||
.font_size(12.)
|
||||
.max_lines(1)
|
||||
.font_weight(if header {
|
||||
FontWeight::SEMI_BOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
})
|
||||
.color(if header {
|
||||
colors::fg_primary()
|
||||
} else {
|
||||
colors::fg_secondary()
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
row.into_element()
|
||||
}
|
||||
|
||||
fn split_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
|
|
|
|||
177
packages/oneclient_db/src/console.rs
Normal file
177
packages/oneclient_db/src/console.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use sqlx::{Column, Row, ValueRef};
|
||||
|
||||
use crate::{DbError, DbPool};
|
||||
|
||||
/// sqlx runs SQLite on a worker thread and requires the SQL string to be
|
||||
/// `'static`. Intern each distinct console query so repeated runs of the same
|
||||
/// text don't leak, while still accepting arbitrary runtime strings.
|
||||
fn intern(sql: &str) -> &'static str {
|
||||
static CACHE: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
|
||||
|
||||
let mut guard = CACHE.lock().unwrap();
|
||||
let cache = guard.get_or_insert_with(HashSet::new);
|
||||
|
||||
if let Some(existing) = cache.get(sql) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
let leaked: &'static str = Box::leak(sql.to_owned().into_boxed_str());
|
||||
cache.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
|
||||
/// Outcome of a console query, stringified for display.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConsoleQueryResult {
|
||||
/// Column headers (empty when the statement returned no rows).
|
||||
pub columns: Vec<String>,
|
||||
/// Row cells, already converted to display strings.
|
||||
pub rows: Vec<Vec<String>>,
|
||||
/// Rows affected by a non-select statement.
|
||||
pub rows_affected: u64,
|
||||
/// Whether the statement returned a result set.
|
||||
pub is_select: bool,
|
||||
}
|
||||
|
||||
/// Execute an arbitrary SQL statement.
|
||||
///
|
||||
/// Statements that start with a row-returning keyword (`SELECT`, `WITH`,
|
||||
/// `PRAGMA`, `EXPLAIN`) are fetched into a table; everything else is executed
|
||||
/// and reports `rows_affected`.
|
||||
pub async fn run_console_query(pool: &DbPool, sql: &str) -> Result<ConsoleQueryResult, DbError> {
|
||||
let sql = intern(sql.trim().trim_end_matches(';').trim());
|
||||
|
||||
if returns_rows(sql) {
|
||||
let fetched = sqlx::query(sql).persistent(false).fetch_all(pool).await?;
|
||||
Ok(rows_to_result(&fetched))
|
||||
} else {
|
||||
let done = sqlx::query(sql).persistent(false).execute(pool).await?;
|
||||
Ok(ConsoleQueryResult {
|
||||
rows_affected: done.rows_affected(),
|
||||
is_select: false,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn returns_rows(sql: &str) -> bool {
|
||||
let head = sql
|
||||
.trim_start()
|
||||
.split(|c: char| c.is_whitespace() || c == '(')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
|
||||
matches!(head.as_str(), "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN")
|
||||
}
|
||||
|
||||
fn rows_to_result(fetched: &[SqliteRow]) -> ConsoleQueryResult {
|
||||
let columns = fetched
|
||||
.first()
|
||||
.map(|row| {
|
||||
row.columns()
|
||||
.iter()
|
||||
.map(|c| c.name().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows = fetched
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.columns().len())
|
||||
.map(|idx| cell_to_string(row, idx))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
ConsoleQueryResult {
|
||||
columns,
|
||||
rows,
|
||||
rows_affected: 0,
|
||||
is_select: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// SQLite is dynamically typed, so probe the raw value and decode accordingly.
|
||||
fn cell_to_string(row: &SqliteRow, idx: usize) -> String {
|
||||
match row.try_get_raw(idx) {
|
||||
Ok(raw) if raw.is_null() => return "NULL".to_string(),
|
||||
Ok(_) => {}
|
||||
Err(_) => return "<err>".to_string(),
|
||||
}
|
||||
|
||||
if let Ok(v) = row.try_get::<String, _>(idx) {
|
||||
return v;
|
||||
}
|
||||
if let Ok(v) = row.try_get::<i64, _>(idx) {
|
||||
return v.to_string();
|
||||
}
|
||||
if let Ok(v) = row.try_get::<f64, _>(idx) {
|
||||
return v.to_string();
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Vec<u8>, _>(idx) {
|
||||
return format!("<blob {} bytes>", v.len());
|
||||
}
|
||||
|
||||
"<?>".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> DbPool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, ratio REAL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_returns_columns_and_rows() {
|
||||
let pool = pool().await;
|
||||
sqlx::query("INSERT INTO t (name, ratio) VALUES ('a', 1.5), (NULL, 2.0)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = run_console_query(&pool, "SELECT id, name, ratio FROM t ORDER BY id;")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(res.is_select);
|
||||
assert_eq!(res.columns, vec!["id", "name", "ratio"]);
|
||||
assert_eq!(res.rows.len(), 2);
|
||||
assert_eq!(res.rows[0], vec!["1", "a", "1.5"]);
|
||||
assert_eq!(res.rows[1][1], "NULL");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_select_reports_rows_affected() {
|
||||
let pool = pool().await;
|
||||
let res = run_console_query(&pool, "INSERT INTO t (name) VALUES ('x'), ('y')")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!res.is_select);
|
||||
assert_eq!(res.rows_affected, 2);
|
||||
assert!(res.rows.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_sql_errors() {
|
||||
let pool = pool().await;
|
||||
assert!(run_console_query(&pool, "SELECT * FROM nope").await.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod error;
|
||||
mod pool;
|
||||
|
||||
pub mod console;
|
||||
pub mod dao;
|
||||
pub mod models;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue