Phase 4: Dioxus frontend with dataset browser and SQL query editor

- ui: Dioxus WASM app with dataset sidebar, SQL editor (Ctrl+Enter), results table
- ui: dynamic API base URL (same-origin for nginx, port-based for local dev)
- gateway: CORS enabled for cross-origin requests
- nginx: lakehouse.devop.live proxies UI (:3300) + API (:3100) on same origin
- justfile: ui-build, ui-serve, sidecar, up commands added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-03-27 06:24:15 -05:00
co-authored by Claude Opus 4.6
parent 78266fdd05
commit 50a8c8013f
13 changed files with 2198 additions and 13 deletions
+288
View File
@@ -0,0 +1,288 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
fn api_base() -> String {
// When served behind nginx on the same origin, use relative paths.
// When running locally (localhost:3300), hit gateway on :3100 directly.
if let Some(window) = web_sys::window() {
if let Ok(origin) = window.location().origin() {
if origin.contains("localhost") || origin.contains("127.0.0.1") {
// Local dev: UI on :3300, gateway on :3100
if let Ok(hostname) = window.location().hostname() {
return format!("http://{}:3100", hostname);
}
}
// Production: same origin, nginx proxies /catalog, /query, etc.
return origin;
}
}
"http://localhost:3100".to_string()
}
fn main() {
dioxus::launch(App);
}
// --- API Types ---
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
struct Dataset {
id: String,
name: String,
schema_fingerprint: String,
objects: Vec<ObjectRef>,
created_at: String,
updated_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
struct ObjectRef {
bucket: String,
key: String,
size_bytes: u64,
created_at: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct QueryResponse {
columns: Vec<ColumnInfo>,
rows: serde_json::Value,
row_count: usize,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct ColumnInfo {
name: String,
data_type: String,
}
// --- API Calls ---
async fn fetch_datasets() -> Result<Vec<Dataset>, String> {
let url = format!("{}/catalog/datasets", api_base());
let resp = reqwest::get(&url).await.map_err(|e| e.to_string())?;
resp.json().await.map_err(|e| e.to_string())
}
async fn execute_query(sql: &str) -> Result<QueryResponse, String> {
let url = format!("{}/query/sql", api_base());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({"sql": sql}))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(text);
}
resp.json().await.map_err(|e| e.to_string())
}
// --- Components ---
#[component]
fn App() -> Element {
let mut datasets = use_signal(Vec::<Dataset>::new);
let mut selected = use_signal(|| None::<String>);
let mut query_text = use_signal(|| String::from("SELECT * FROM "));
let mut query_result = use_signal(|| None::<Result<QueryResponse, String>>);
let mut loading = use_signal(|| false);
let mut ds_loading = use_signal(|| true);
let mut ds_error = use_signal(|| None::<String>);
// Load datasets on mount
use_effect(move || {
spawn(async move {
ds_loading.set(true);
ds_error.set(None);
match fetch_datasets().await {
Ok(ds) => datasets.set(ds),
Err(e) => ds_error.set(Some(e)),
}
ds_loading.set(false);
});
});
let refresh_datasets = move |_| {
spawn(async move {
ds_loading.set(true);
ds_error.set(None);
match fetch_datasets().await {
Ok(ds) => datasets.set(ds),
Err(e) => ds_error.set(Some(e)),
}
ds_loading.set(false);
});
};
let run_query = move |_| {
let sql = query_text.read().clone();
if sql.trim().is_empty() {
return;
}
spawn(async move {
loading.set(true);
query_result.set(None);
let result = execute_query(&sql).await;
query_result.set(Some(result));
loading.set(false);
});
};
let mut select_dataset = move |name: String| {
selected.set(Some(name.clone()));
query_text.set(format!("SELECT * FROM {name} LIMIT 100"));
};
rsx! {
div { class: "app",
// Header
div { class: "header",
h1 { "LAKEHOUSE" }
div { class: "status",
"{datasets.read().len()} datasets"
}
}
// Main
div { class: "main",
// Sidebar
div { class: "sidebar",
div { class: "sidebar-header",
span { "Datasets" }
button {
class: "refresh-btn",
onclick: refresh_datasets,
"↻"
}
}
div { class: "sidebar-list",
if *ds_loading.read() {
div { class: "loading", "loading..." }
} else if let Some(err) = ds_error.read().as_ref() {
div { class: "error", "{err}" }
} else if datasets.read().is_empty() {
div { class: "empty", "no datasets registered" }
} else {
for ds in datasets.read().iter() {
{
let name = ds.name.clone();
let name2 = ds.name.clone();
let is_active = selected.read().as_ref() == Some(&ds.name);
let obj_count = ds.objects.len();
rsx! {
div {
class: if is_active { "dataset-item active" } else { "dataset-item" },
onclick: move |_| select_dataset(name.clone()),
div { class: "name", "{name2}" }
div { class: "meta", "{obj_count} object(s)" }
}
}
}
}
}
}
}
// Content
div { class: "content",
// Query editor
div { class: "query-editor",
textarea {
value: "{query_text}",
placeholder: "SELECT * FROM dataset LIMIT 100",
oninput: move |e| query_text.set(e.value()),
onkeydown: move |e| {
if e.key() == Key::Enter && e.modifiers().ctrl() {
let sql = query_text.read().clone();
if !sql.trim().is_empty() {
spawn(async move {
loading.set(true);
query_result.set(None);
let result = execute_query(&sql).await;
query_result.set(Some(result));
loading.set(false);
});
}
}
},
}
button {
class: "btn",
disabled: *loading.read(),
onclick: run_query,
if *loading.read() { "running..." } else { "Run ⌃↵" }
}
}
// Results
div { class: "results",
if *loading.read() {
div { class: "loading", "executing query..." }
} else {
match query_result.read().as_ref() {
None => rsx! {
div { class: "empty", "enter a SQL query and press Run" }
},
Some(Err(e)) => rsx! {
div { class: "error", "{e}" }
},
Some(Ok(resp)) => rsx! {
ResultsTable { response: resp.clone() }
},
}
}
}
}
}
}
}
}
#[component]
fn ResultsTable(response: QueryResponse) -> Element {
let rows = response.rows.as_array();
rsx! {
div { class: "results-info",
"{response.row_count} row(s) · {response.columns.len()} column(s)"
}
if response.row_count == 0 {
div { class: "empty", "query returned no rows" }
} else if let Some(rows) = rows {
table {
thead {
tr {
for col in response.columns.iter() {
th { title: "{col.data_type}", "{col.name}" }
}
}
}
tbody {
for row in rows.iter() {
tr {
for col in response.columns.iter() {
td {
{format_cell(row.get(&col.name))}
}
}
}
}
}
}
}
}
}
fn format_cell(val: Option<&serde_json::Value>) -> String {
match val {
None | Some(serde_json::Value::Null) => "—".to_string(),
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Number(n)) => n.to_string(),
Some(serde_json::Value::Bool(b)) => b.to_string(),
Some(other) => other.to_string(),
}
}