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:
parent
78266fdd05
commit
50a8c8013f
1586
Cargo.lock
generated
1586
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ members = [
|
|||||||
"crates/queryd",
|
"crates/queryd",
|
||||||
"crates/aibridge",
|
"crates/aibridge",
|
||||||
"crates/gateway",
|
"crates/gateway",
|
||||||
|
"crates/ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
@ -32,6 +33,10 @@ async fn main() {
|
|||||||
.nest("/catalog", catalogd::service::router(registry))
|
.nest("/catalog", catalogd::service::router(registry))
|
||||||
.nest("/query", queryd::service::router(engine))
|
.nest("/query", queryd::service::router(engine))
|
||||||
.nest("/ai", aibridge::service::router(ai_client))
|
.nest("/ai", aibridge::service::router(ai_client))
|
||||||
|
.layer(CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any))
|
||||||
.layer(TraceLayer::new_for_http());
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
let addr = "0.0.0.0:3100";
|
let addr = "0.0.0.0:3100";
|
||||||
|
|||||||
12
crates/ui/Cargo.toml
Normal file
12
crates/ui/Cargo.toml
Normal 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
8
crates/ui/Dioxus.toml
Normal 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
248
crates/ui/assets/style.css
Normal 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
288
crates/ui/src/main.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
BIN
data/datasets/events.parquet
Normal file
BIN
data/datasets/events.parquet
Normal file
Binary file not shown.
BIN
data/datasets/scores.parquet
Normal file
BIN
data/datasets/scores.parquet
Normal file
Binary file not shown.
@ -40,12 +40,13 @@
|
|||||||
**Gate: PASSED** — Gateway → aibridge → sidecar → Ollama → real 768d embeddings + generation.
|
**Gate: PASSED** — Gateway → aibridge → sidecar → Ollama → real 768d embeddings + generation.
|
||||||
|
|
||||||
## Phase 4: Frontend
|
## Phase 4: Frontend
|
||||||
- [ ] 4.1 — Dioxus scaffold, WASM build
|
- [x] 4.1 — Dioxus scaffold, WASM build (dx build --platform web)
|
||||||
- [ ] 4.2 — Dataset browser
|
- [x] 4.2 — Dataset browser (sidebar, click to select, refresh)
|
||||||
- [ ] 4.3 — Query editor + results table
|
- [x] 4.3 — Query editor + results table (Ctrl+Enter to run, column types, row count)
|
||||||
- [ ] 4.4 — Error display + loading states
|
- [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
|
## Phase 5: Hardening
|
||||||
- [ ] 5.1 — Proto definitions
|
- [ ] 5.1 — Proto definitions
|
||||||
|
|||||||
22
justfile
22
justfile
@ -15,6 +15,28 @@ test:
|
|||||||
run:
|
run:
|
||||||
cargo run --bin gateway
|
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 without building
|
||||||
check:
|
check:
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user