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
parent 78266fdd05
commit 50a8c8013f
13 changed files with 2198 additions and 13 deletions

1586
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ members = [
"crates/queryd",
"crates/aibridge",
"crates/gateway",
"crates/ui",
]
[workspace.dependencies]

View File

@ -1,4 +1,5 @@
use axum::{Router, routing::get};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
@ -32,6 +33,10 @@ async fn main() {
.nest("/catalog", catalogd::service::router(registry))
.nest("/query", queryd::service::router(engine))
.nest("/ai", aibridge::service::router(ai_client))
.layer(CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any))
.layer(TraceLayer::new_for_http());
let addr = "0.0.0.0:3100";

12
crates/ui/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "ui"
version = "0.1.0"
edition = "2024"
[dependencies]
dioxus = { version = "0.7", features = ["web"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
web-sys = { version = "0.3", features = ["Window", "Location"] }

8
crates/ui/Dioxus.toml Normal file
View File

@ -0,0 +1,8 @@
[application]
name = "lakehouse"
[web.app]
title = "Lakehouse"
[web.resource.dev]
style = ["/assets/style.css"]

248
crates/ui/assets/style.css Normal file
View File

@ -0,0 +1,248 @@
:root {
--bg: #0a0a0f;
--surface: #12121a;
--border: #2a2a3a;
--text: #e0e0e8;
--text-dim: #888898;
--accent: #6c5ce7;
--accent-dim: #4a3db8;
--success: #00d2a0;
--error: #ff6b6b;
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--mono);
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
}
.app {
display: grid;
grid-template-rows: 48px 1fr;
height: 100vh;
overflow: hidden;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.header h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--accent);
}
.header .status {
font-size: 11px;
color: var(--text-dim);
}
/* Main layout */
.main {
display: grid;
grid-template-columns: 280px 1fr;
overflow: hidden;
}
/* Sidebar */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 12px 16px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-list {
overflow-y: auto;
flex: 1;
}
.dataset-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.dataset-item:hover {
background: rgba(108, 92, 231, 0.1);
}
.dataset-item.active {
background: rgba(108, 92, 231, 0.15);
border-left: 2px solid var(--accent);
}
.dataset-item .name {
font-weight: 600;
font-size: 13px;
}
.dataset-item .meta {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
}
/* Content area */
.content {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Query editor */
.query-editor {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 8px;
}
.query-editor textarea {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 10px;
border-radius: 4px;
resize: none;
min-height: 60px;
outline: none;
}
.query-editor textarea:focus {
border-color: var(--accent);
}
.btn {
background: var(--accent);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
font-weight: 600;
white-space: nowrap;
transition: background 0.1s;
}
.btn:hover { background: var(--accent-dim); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
/* Results */
.results {
flex: 1;
overflow: auto;
padding: 16px;
}
.results-info {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th {
text-align: left;
padding: 8px 12px;
background: var(--surface);
border-bottom: 2px solid var(--accent);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
position: sticky;
top: 0;
}
td {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
}
tr:hover td {
background: rgba(108, 92, 231, 0.05);
}
/* States */
.loading {
color: var(--accent);
padding: 20px;
text-align: center;
}
.error {
color: var(--error);
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
padding: 12px 16px;
border-radius: 4px;
font-size: 12px;
}
.empty {
color: var(--text-dim);
padding: 40px 20px;
text-align: center;
font-size: 12px;
}
/* Refresh button */
.refresh-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 2px 8px;
border-radius: 3px;
font-family: var(--mono);
font-size: 10px;
cursor: pointer;
}
.refresh-btn:hover {
border-color: var(--accent);
color: var(--accent);
}

288
crates/ui/src/main.rs Normal file
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(),
}
}

View File

@ -0,0 +1,15 @@
{
"id": "4828e537-2524-4af1-9acb-ed075d11e7ee",
"name": "events",
"schema_fingerprint": "test",
"objects": [
{
"bucket": "data",
"key": "datasets/events.parquet",
"size_bytes": 800,
"created_at": "2026-03-27T11:18:02.318643343Z"
}
],
"created_at": "2026-03-27T11:18:02.318648066Z",
"updated_at": "2026-03-27T11:18:02.318648066Z"
}

View File

@ -0,0 +1,15 @@
{
"id": "f4c0f89b-85bf-4af6-8692-8e1cc81c357a",
"name": "scores",
"schema_fingerprint": "test",
"objects": [
{
"bucket": "data",
"key": "datasets/scores.parquet",
"size_bytes": 800,
"created_at": "2026-03-27T11:18:02.317035729Z"
}
],
"created_at": "2026-03-27T11:18:02.317042772Z",
"updated_at": "2026-03-27T11:18:02.317042772Z"
}

Binary file not shown.

Binary file not shown.

View File

@ -40,12 +40,13 @@
**Gate: PASSED** — Gateway → aibridge → sidecar → Ollama → real 768d embeddings + generation.
## Phase 4: Frontend
- [ ] 4.1 — Dioxus scaffold, WASM build
- [ ] 4.2 — Dataset browser
- [ ] 4.3 — Query editor + results table
- [ ] 4.4 — Error display + loading states
- [x] 4.1 — Dioxus scaffold, WASM build (dx build --platform web)
- [x] 4.2 — Dataset browser (sidebar, click to select, refresh)
- [x] 4.3 — Query editor + results table (Ctrl+Enter to run, column types, row count)
- [x] 4.4 — Error display + loading states
- [x] 4.5 — Nginx proxy (lakehouse.devop.live), same-origin API detection
**Gate:** Browse datasets and query from browser.
**Gate: PASSED** Browse datasets and query from browser at lakehouse.devop.live.
## Phase 5: Hardening
- [ ] 5.1 — Proto definitions

View File

@ -15,6 +15,28 @@ test:
run:
cargo run --bin gateway
# Build UI (WASM)
ui-build:
cd crates/ui && dx build --platform web
# Serve UI (dev)
ui-serve:
cd target/dx/ui/debug/web/public && python3 -m http.server 3300 --bind 0.0.0.0
# Start sidecar
sidecar:
cd sidecar && uvicorn sidecar.main:app --host 0.0.0.0 --port 3200
# Start everything (gateway + sidecar + UI)
up:
@echo "Starting gateway on :3100..."
@cargo run --bin gateway &
@echo "Starting sidecar on :3200..."
@cd sidecar && uvicorn sidecar.main:app --host 0.0.0.0 --port 3200 &
@echo "Starting UI on :3300..."
@cd target/dx/ui/debug/web/public && python3 -m http.server 3300 --bind 0.0.0.0 &
@echo "All services started."
# Check without building
check:
cargo check --workspace