feat(ui): Add SQL debug console in debug page

This commit is contained in:
LynithDev 2026-07-19 17:37:04 +02:00
parent c881bf858d
commit 964cfa61b3
3 changed files with 349 additions and 0 deletions

View file

@ -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())

View 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());
}
}

View file

@ -1,6 +1,7 @@
mod error;
mod pool;
pub mod console;
pub mod dao;
pub mod models;