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:
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user