diff --git a/crates/ui/assets/style.css b/crates/ui/assets/style.css index 5c02bf6..b744d2b 100644 --- a/crates/ui/assets/style.css +++ b/crates/ui/assets/style.css @@ -32,10 +32,10 @@ body { .header { display: flex; align-items: center; - justify-content: space-between; padding: 0 20px; background: var(--surface); border-bottom: 1px solid var(--border); + gap: 24px; } .header h1 { @@ -45,15 +45,31 @@ body { color: var(--accent); } -.header .status { - font-size: 11px; +/* Tabs */ +.tabs { display: flex; gap: 2px; } + +.tab { + background: none; + border: none; color: var(--text-dim); + font-family: var(--mono); + font-size: 12px; + padding: 6px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.tab:hover { color: var(--text); } +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); } /* Main layout */ .main { display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: 240px 1fr; overflow: hidden; } @@ -78,10 +94,7 @@ body { align-items: center; } -.sidebar-list { - overflow-y: auto; - flex: 1; -} +.sidebar-list { overflow-y: auto; flex: 1; } .dataset-item { padding: 10px 16px; @@ -90,25 +103,15 @@ body { transition: background 0.1s; } -.dataset-item:hover { - background: rgba(108, 92, 231, 0.1); -} +.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; -} +.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 { @@ -139,10 +142,9 @@ body { outline: none; } -.query-editor textarea:focus { - border-color: var(--accent); -} +.query-editor textarea:focus { border-color: var(--accent); } +/* Buttons */ .btn { background: var(--accent); color: white; @@ -159,30 +161,26 @@ body { .btn:hover { background: var(--accent-dim); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-sm { padding: 4px 10px; font-size: 11px; } -.btn-sm { - padding: 4px 10px; - font-size: 11px; +.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); } + /* Results */ -.results { - flex: 1; - overflow: auto; - padding: 16px; -} +.results { flex: 1; overflow: auto; padding: 16px; } +.results-info { font-size: 11px; color: var(--text-dim); margin-bottom: 8px; } -.results-info { - font-size: 11px; - color: var(--text-dim); - margin-bottom: 8px; -} - -table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} +table { width: 100%; border-collapse: collapse; font-size: 12px; } th { text-align: left; @@ -198,21 +196,11 @@ th { top: 0; } -td { - padding: 6px 12px; - border-bottom: 1px solid var(--border); -} - -tr:hover td { - background: rgba(108, 92, 231, 0.05); -} +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; -} +.loading { color: var(--accent); padding: 20px; text-align: center; } .error { color: var(--error); @@ -223,26 +211,173 @@ tr:hover td { font-size: 12px; } -.empty { +.success { + color: var(--success); + background: rgba(0, 210, 160, 0.1); + border: 1px solid rgba(0, 210, 160, 0.3); + padding: 12px 16px; + border-radius: 4px; + font-size: 12px; + margin-top: 8px; +} + +.empty { color: var(--text-dim); padding: 40px 20px; text-align: center; font-size: 12px; } + +/* Panels */ +.panel { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.panel-section { + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.panel-section:last-child { border-bottom: none; } + +.panel-section h3 { + font-size: 14px; + font-weight: 600; + color: var(--accent); + margin-bottom: 6px; +} + +.hint { + font-size: 11px; color: var(--text-dim); - padding: 40px 20px; - text-align: center; + margin-bottom: 12px; +} + +/* Forms */ +.form-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.form-row label { + font-size: 11px; + color: var(--text-dim); + min-width: 80px; +} + +.form-row input, input { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + flex: 1; + outline: none; +} + +.form-row input:focus, input:focus { border-color: var(--accent); } + +.row { display: flex; gap: 8px; margin-bottom: 8px; } + +/* Object list */ +.object-list { margin-top: 8px; } + +.object-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid var(--border); font-size: 12px; } -/* Refresh button */ -.refresh-btn { - background: none; +.mono { font-family: var(--mono); } + +/* AI panel */ +.ai-panel { max-width: 800px; } + +.input-area { + width: 100%; + background: var(--surface); border: 1px solid var(--border); - color: var(--text-dim); - padding: 2px 8px; - border-radius: 3px; + color: var(--text); font-family: var(--mono); - font-size: 10px; - cursor: pointer; + font-size: 12px; + padding: 10px; + border-radius: 4px; + resize: vertical; + min-height: 60px; + outline: none; + margin-bottom: 8px; } -.refresh-btn:hover { - border-color: var(--accent); - color: var(--accent); +.input-area:focus { border-color: var(--accent); } + +.result-box { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 12px; + margin-top: 10px; } + +.result-meta { + font-size: 11px; + color: var(--text-dim); + margin-bottom: 8px; +} + +.embed-row { + font-size: 11px; + color: var(--text); + padding: 2px 0; +} + +.gen-text { + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; +} + +.rerank-row { + display: flex; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.rerank-row:last-child { border-bottom: none; } + +.score { + background: var(--accent); + color: white; + padding: 2px 8px; + border-radius: 3px; + font-weight: 600; + font-size: 11px; + min-width: 32px; + text-align: center; +} + +/* Status checks */ +.check-results { margin-top: 16px; } + +.check-row { + display: flex; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + font-size: 12px; + border-left: 3px solid transparent; +} + +.check-row.check-pass { border-left-color: var(--success); } +.check-row.check-fail { border-left-color: var(--error); } + +.check-name { font-weight: 600; } +.check-result { color: var(--text-dim); text-align: right; max-width: 60%; } +.check-pass .check-result { color: var(--success); } +.check-fail .check-result { color: var(--error); } diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs index ffaceb2..0dc57dd 100644 --- a/crates/ui/src/main.rs +++ b/crates/ui/src/main.rs @@ -2,8 +2,6 @@ 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") { @@ -12,7 +10,7 @@ fn api_base() -> String { return format!("http://{}:3100", hostname); } } - // Production: same origin, nginx proxies /catalog, /query, etc. + // Production: nginx proxies /catalog, /query, /ai, /storage on same origin return origin; } } @@ -30,13 +28,13 @@ struct Dataset { id: String, name: String, schema_fingerprint: String, - objects: Vec, + objects: Vec, created_at: String, updated_at: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -struct ObjectRef { +struct ObjRef { bucket: String, key: String, size_bytes: u64, @@ -56,40 +54,170 @@ struct ColumnInfo { data_type: String, } +#[derive(Debug, Clone, Deserialize, PartialEq)] +struct EmbedResponse { + embeddings: Vec>, + model: String, + dimensions: usize, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +struct GenerateResponse { + text: String, + model: String, + tokens_evaluated: Option, + tokens_generated: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +struct RerankResult { + index: usize, + text: String, + score: f64, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +struct RerankResponse { + results: Vec, + model: String, +} + // --- API Calls --- async fn fetch_datasets() -> Result, String> { - let url = format!("{}/catalog/datasets", api_base()); - let resp = reqwest::get(&url).await.map_err(|e| e.to_string())?; + let resp = reqwest::get(&format!("{}/catalog/datasets", api_base())) + .await.map_err(|e| e.to_string())?; resp.json().await.map_err(|e| e.to_string()) } async fn execute_query(sql: &str) -> Result { - let url = format!("{}/query/sql", api_base()); let client = reqwest::Client::new(); - let resp = client - .post(&url) + let resp = client.post(&format!("{}/query/sql", api_base())) .json(&serde_json::json!({"sql": sql})) - .send() - .await - .map_err(|e| e.to_string())?; - + .send().await.map_err(|e| e.to_string())?; if !resp.status().is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(text); + return Err(resp.text().await.unwrap_or_default()); } resp.json().await.map_err(|e| e.to_string()) } +async fn fetch_health(path: &str) -> Result { + let resp = reqwest::get(&format!("{}{}", api_base(), path)) + .await.map_err(|e| e.to_string())?; + resp.text().await.map_err(|e| e.to_string()) +} + +async fn list_objects(prefix: &str) -> Result, String> { + let url = if prefix.is_empty() { + format!("{}/storage/objects", api_base()) + } else { + format!("{}/storage/objects?prefix={}", api_base(), prefix) + }; + let resp = reqwest::get(&url).await.map_err(|e| e.to_string())?; + resp.json().await.map_err(|e| e.to_string()) +} + +async fn upload_object(key: &str, data: Vec) -> Result { + let client = reqwest::Client::new(); + let resp = client.put(&format!("{}/storage/objects/{}", api_base(), key)) + .body(data) + .send().await.map_err(|e| e.to_string())?; + resp.text().await.map_err(|e| e.to_string()) +} + +async fn register_dataset(name: &str, fingerprint: &str, key: &str, size: u64) -> Result { + let client = reqwest::Client::new(); + let resp = client.post(&format!("{}/catalog/datasets", api_base())) + .json(&serde_json::json!({ + "name": name, + "schema_fingerprint": fingerprint, + "objects": [{"bucket": "data", "key": key, "size_bytes": size}] + })) + .send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(resp.text().await.unwrap_or_default()); + } + resp.json().await.map_err(|e| e.to_string()) +} + +async fn ai_embed(texts: Vec) -> Result { + let client = reqwest::Client::new(); + let resp = client.post(&format!("{}/ai/embed", api_base())) + .json(&serde_json::json!({"texts": texts})) + .send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(resp.text().await.unwrap_or_default()); + } + resp.json().await.map_err(|e| e.to_string()) +} + +async fn ai_generate(prompt: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client.post(&format!("{}/ai/generate", api_base())) + .json(&serde_json::json!({"prompt": prompt, "max_tokens": 256})) + .send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(resp.text().await.unwrap_or_default()); + } + resp.json().await.map_err(|e| e.to_string()) +} + +async fn ai_rerank(query: &str, documents: Vec) -> Result { + let client = reqwest::Client::new(); + let resp = client.post(&format!("{}/ai/rerank", api_base())) + .json(&serde_json::json!({"query": query, "documents": documents, "top_k": 5})) + .send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(resp.text().await.unwrap_or_default()); + } + resp.json().await.map_err(|e| e.to_string()) +} + +async fn create_sample_data() -> Result { + // Create a simple CSV-like Parquet by uploading raw bytes and registering + // We'll use the gateway to generate it via a SQL query trick: + // First upload some raw data, then register it + // Actually, we need real Parquet. Let's use the query engine to generate data + // by creating a VALUES-based query first, then storing it. + + // Step 1: Upload a pre-built parquet via the generate endpoint + // Since we can't create Parquet in WASM, we'll call a special endpoint. + // For now, let's use a workaround — upload raw CSV-like data and note it won't be queryable + // OR better: call generate to create test data description + let client = reqwest::Client::new(); + + // Use the sidecar to generate sample data description + let resp = client.post(&format!("{}/ai/generate", api_base())) + .json(&serde_json::json!({ + "prompt": "Generate a JSON array of 5 sample employee records with fields: id (int), name (string), department (string), salary (float). Output ONLY the JSON array, nothing else.", + "max_tokens": 512, + "temperature": 0.3 + })) + .send().await.map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + return Err(resp.text().await.unwrap_or_default()); + } + let result: GenerateResponse = resp.json().await.map_err(|e| e.to_string())?; + Ok(result.text) +} + +// --- Tabs --- +#[derive(Clone, PartialEq)] +enum Tab { + Query, + Storage, + Ai, + Status, +} + // --- Components --- #[component] fn App() -> Element { + let mut active_tab = use_signal(|| Tab::Query); let mut datasets = use_signal(Vec::::new); let mut selected = use_signal(|| None::); - let mut query_text = use_signal(|| String::from("SELECT * FROM ")); - let mut query_result = use_signal(|| None::>); - let mut loading = use_signal(|| false); let mut ds_loading = use_signal(|| true); let mut ds_error = use_signal(|| None::); @@ -118,23 +246,9 @@ fn App() -> Element { }); }; - 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")); + active_tab.set(Tab::Query); }; rsx! { @@ -142,8 +256,27 @@ fn App() -> Element { // Header div { class: "header", h1 { "LAKEHOUSE" } - div { class: "status", - "{datasets.read().len()} datasets" + div { class: "tabs", + button { + class: if *active_tab.read() == Tab::Query { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Query), + "Query" + } + button { + class: if *active_tab.read() == Tab::Storage { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Storage), + "Storage" + } + button { + class: if *active_tab.read() == Tab::Ai { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Ai), + "AI" + } + button { + class: if *active_tab.read() == Tab::Status { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Status), + "Status" + } } } @@ -165,7 +298,7 @@ fn App() -> Element { } 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" } + div { class: "empty", "no datasets" } } else { for ds in datasets.read().iter() { { @@ -187,52 +320,457 @@ fn App() -> Element { } } - // Content + // Content — tab panels 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 ⌃↵" } + match *active_tab.read() { + Tab::Query => rsx! { QueryPanel { selected: selected.read().clone() } }, + Tab::Storage => rsx! { StoragePanel {} }, + Tab::Ai => rsx! { AiPanel {} }, + Tab::Status => rsx! { StatusPanel {} }, + } + } + } + } + } +} + +// === QUERY PANEL === + +#[component] +fn QueryPanel(selected: Option) -> Element { + let mut query_text = use_signal(move || { + match selected { + Some(ref name) => format!("SELECT * FROM {} LIMIT 100", name), + None => "SELECT * FROM ".to_string(), + } + }); + let mut query_result = use_signal(|| None::>); + let mut loading = use_signal(|| 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); + }); + }; + + rsx! { + 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 ⌃↵" } + } + } + 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() } }, + } + } + } + } +} - // 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() } - }, +// === STORAGE PANEL === + +#[component] +fn StoragePanel() -> Element { + let mut objects = use_signal(Vec::::new); + let mut obj_loading = use_signal(|| false); + let mut obj_error = use_signal(|| None::); + let mut reg_name = use_signal(|| String::new()); + let mut reg_key = use_signal(|| String::new()); + let mut reg_status = use_signal(|| None::>); + + let load_objects = move |_| { + spawn(async move { + obj_loading.set(true); + obj_error.set(None); + match list_objects("").await { + Ok(keys) => objects.set(keys), + Err(e) => obj_error.set(Some(e)), + } + obj_loading.set(false); + }); + }; + + let do_register = move |_| { + let name = reg_name.read().clone(); + let key = reg_key.read().clone(); + if name.is_empty() || key.is_empty() { return; } + spawn(async move { + reg_status.set(None); + match register_dataset(&name, "from-ui", &key, 0).await { + Ok(ds) => reg_status.set(Some(Ok(format!("registered: {} ({})", ds.name, ds.id)))), + Err(e) => reg_status.set(Some(Err(e))), + } + }); + }; + + rsx! { + div { class: "panel", + div { class: "panel-section", + h3 { "Object Storage" } + div { class: "row", + button { class: "btn btn-sm", onclick: load_objects, "List Objects" } + } + if *obj_loading.read() { + div { class: "loading", "loading..." } + } else if let Some(err) = obj_error.read().as_ref() { + div { class: "error", "{err}" } + } else if !objects.read().is_empty() { + div { class: "object-list", + for key in objects.read().iter() { + { + let k = key.clone(); + let k2 = key.clone(); + rsx! { + div { class: "object-item", + span { class: "mono", "{k}" } + button { + class: "btn btn-sm", + onclick: move |_| { + reg_key.set(k2.clone()); + }, + "use" + } + } + } + } + } + } + } + } + + div { class: "panel-section", + h3 { "Register Dataset" } + p { class: "hint", "Point a dataset name at an existing storage key" } + div { class: "form-row", + label { "Name" } + input { + value: "{reg_name}", + placeholder: "my_dataset", + oninput: move |e| reg_name.set(e.value()), + } + } + div { class: "form-row", + label { "Storage Key" } + input { + value: "{reg_key}", + placeholder: "datasets/file.parquet", + oninput: move |e| reg_key.set(e.value()), + } + } + button { class: "btn", onclick: do_register, "Register" } + if let Some(result) = reg_status.read().as_ref() { + match result { + Ok(msg) => rsx! { div { class: "success", "{msg}" } }, + Err(e) => rsx! { div { class: "error", "{e}" } }, + } + } + } + } + } +} + +// === AI PANEL === + +#[component] +fn AiPanel() -> Element { + // Embed state + let mut embed_input = use_signal(|| String::from("The quick brown fox jumps over the lazy dog")); + let mut embed_result = use_signal(|| None::>); + let mut embed_loading = use_signal(|| false); + + // Generate state + let mut genr_input = use_signal(|| String::from("Explain what a lakehouse architecture is in 2 sentences.")); + let mut genr_result = use_signal(|| None::>); + let mut genr_loading = use_signal(|| false); + + // Rerank state + let mut rerank_query = use_signal(|| String::from("rust programming language")); + let mut rerank_docs = use_signal(|| String::from("Rust is a systems programming language\nPython is great for data science\nRust focuses on memory safety\nJavaScript runs in browsers\nRust uses zero-cost abstractions")); + let mut rerank_result = use_signal(|| None::>); + let mut rerank_loading = use_signal(|| false); + + let do_embed = move |_| { + let text = embed_input.read().clone(); + if text.is_empty() { return; } + spawn(async move { + embed_loading.set(true); + embed_result.set(None); + let texts: Vec = text.lines().map(|l| l.to_string()).filter(|l| !l.is_empty()).collect(); + let result = ai_embed(texts).await; + embed_result.set(Some(result)); + embed_loading.set(false); + }); + }; + + let do_generate = move |_| { + let prompt = genr_input.read().clone(); + if prompt.is_empty() { return; } + spawn(async move { + genr_loading.set(true); + genr_result.set(None); + let result = ai_generate(&prompt).await; + genr_result.set(Some(result)); + genr_loading.set(false); + }); + }; + + let do_rerank = move |_| { + let query = rerank_query.read().clone(); + let docs_text = rerank_docs.read().clone(); + if query.is_empty() || docs_text.is_empty() { return; } + let docs: Vec = docs_text.lines().map(|l| l.to_string()).filter(|l| !l.is_empty()).collect(); + spawn(async move { + rerank_loading.set(true); + rerank_result.set(None); + let result = ai_rerank(&query, docs).await; + rerank_result.set(Some(result)); + rerank_loading.set(false); + }); + }; + + rsx! { + div { class: "panel ai-panel", + // --- Embed --- + div { class: "panel-section", + h3 { "Embed" } + p { class: "hint", "Generate embeddings via nomic-embed-text (one text per line)" } + textarea { + class: "input-area", + value: "{embed_input}", + oninput: move |e| embed_input.set(e.value()), + } + button { + class: "btn", + disabled: *embed_loading.read(), + onclick: do_embed, + if *embed_loading.read() { "embedding..." } else { "Embed" } + } + if let Some(result) = embed_result.read().as_ref() { + match result { + Ok(resp) => rsx! { + div { class: "result-box", + div { class: "result-meta", + "model: {resp.model} · dims: {resp.dimensions} · vectors: {resp.embeddings.len()}" + } + for (i, emb) in resp.embeddings.iter().enumerate() { + { + let preview: Vec = emb.iter().take(5).map(|v| format!("{:.4}", v)).collect(); + let preview_str = preview.join(", "); + rsx! { + div { class: "mono embed-row", + "vec[{i}]: [{preview_str}, ...]" + } + } + } + } + } + }, + Err(e) => rsx! { div { class: "error", "{e}" } }, + } + } + } + + // --- Generate --- + div { class: "panel-section", + h3 { "Generate" } + p { class: "hint", "Text generation via Ollama (qwen2.5)" } + textarea { + class: "input-area", + value: "{genr_input}", + oninput: move |e| genr_input.set(e.value()), + } + button { + class: "btn", + disabled: *genr_loading.read(), + onclick: do_generate, + if *genr_loading.read() { "generating..." } else { "Generate" } + } + if let Some(result) = genr_result.read().as_ref() { + match result { + Ok(resp) => rsx! { + div { class: "result-box", + div { class: "result-meta", + "model: {resp.model} · eval: {resp.tokens_evaluated:?} · gen: {resp.tokens_generated:?}" + } + div { class: "gen-text", "{resp.text}" } + } + }, + Err(e) => rsx! { div { class: "error", "{e}" } }, + } + } + } + + // --- Rerank --- + div { class: "panel-section", + h3 { "Rerank" } + p { class: "hint", "Score documents against a query (one doc per line)" } + div { class: "form-row", + label { "Query" } + input { + value: "{rerank_query}", + oninput: move |e| rerank_query.set(e.value()), + } + } + textarea { + class: "input-area", + value: "{rerank_docs}", + oninput: move |e| rerank_docs.set(e.value()), + } + button { + class: "btn", + disabled: *rerank_loading.read(), + onclick: do_rerank, + if *rerank_loading.read() { "reranking..." } else { "Rerank" } + } + if let Some(result) = rerank_result.read().as_ref() { + match result { + Ok(resp) => rsx! { + div { class: "result-box", + div { class: "result-meta", "model: {resp.model}" } + for doc in resp.results.iter() { + { + let score = doc.score; + let text = doc.text.clone(); + rsx! { + div { class: "rerank-row", + span { class: "score", "{score:.1}" } + span { "{text}" } + } + } + } + } + } + }, + Err(e) => rsx! { div { class: "error", "{e}" } }, + } + } + } + } + } +} + +// === STATUS PANEL === + +#[component] +fn StatusPanel() -> Element { + let mut results = use_signal(Vec::<(String, Result)>::new); + let mut checking = use_signal(|| false); + + let run_checks = move |_| { + spawn(async move { + checking.set(true); + results.set(vec![]); + + let mut checks = vec![]; + + // Gateway health + let r = fetch_health("/health").await; + checks.push(("Gateway /health".to_string(), r)); + + // Storage health + let r = fetch_health("/storage/health").await; + checks.push(("Storage /storage/health".to_string(), r)); + + // Catalog health + let r = fetch_health("/catalog/health").await; + checks.push(("Catalog /catalog/health".to_string(), r)); + + // Query health + let r = fetch_health("/query/health").await; + checks.push(("Query /query/health".to_string(), r)); + + // AI health + let r = fetch_health("/ai/health").await; + checks.push(("AI Bridge /ai/health".to_string(), r)); + + // Storage: list objects + let r = list_objects("").await.map(|keys| format!("{} objects", keys.len())); + checks.push(("Storage: list objects".to_string(), r)); + + // Catalog: list datasets + let r = fetch_datasets().await.map(|ds| format!("{} datasets", ds.len())); + checks.push(("Catalog: list datasets".to_string(), r)); + + // Query: simple SQL + let r = execute_query("SELECT 1 as test").await + .map(|q| format!("{} row(s)", q.row_count)); + checks.push(("Query: SELECT 1".to_string(), r)); + + // AI: embed test + let r = ai_embed(vec!["test".to_string()]).await + .map(|e| format!("{}d vectors from {}", e.dimensions, e.model)); + checks.push(("AI: embed".to_string(), r)); + + // AI: generate test + let r = ai_generate("Say hello in one word.").await + .map(|g| format!("\"{}\" ({})", g.text.trim().chars().take(50).collect::(), g.model)); + checks.push(("AI: generate".to_string(), r)); + + results.set(checks); + checking.set(false); + }); + }; + + rsx! { + div { class: "panel", + div { class: "panel-section", + h3 { "System Health" } + p { class: "hint", "Tests every service in the stack: gateway, storage, catalog, query, AI" } + button { + class: "btn", + disabled: *checking.read(), + onclick: run_checks, + if *checking.read() { "checking..." } else { "Run All Checks" } + } + } + if !results.read().is_empty() { + div { class: "check-results", + for (name, result) in results.read().iter() { + { + let name = name.clone(); + let (class, text) = match result { + Ok(msg) => ("check-pass", format!("OK — {}", msg)), + Err(e) => ("check-fail", format!("FAIL — {}", e)), + }; + rsx! { + div { class: "check-row {class}", + span { class: "check-name", "{name}" } + span { class: "check-result", "{text}" } + } } } } @@ -242,10 +780,11 @@ fn App() -> Element { } } +// === RESULTS TABLE === + #[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)" @@ -265,9 +804,7 @@ fn ResultsTable(response: QueryResponse) -> Element { for row in rows.iter() { tr { for col in response.columns.iter() { - td { - {format_cell(row.get(&col.name))} - } + td { {format_cell(row.get(&col.name))} } } } } diff --git a/data/_catalog/manifests/1f81445a-404f-48ea-bd72-00f6721ee18b.json b/data/_catalog/manifests/1f81445a-404f-48ea-bd72-00f6721ee18b.json new file mode 100644 index 0000000..be47193 --- /dev/null +++ b/data/_catalog/manifests/1f81445a-404f-48ea-bd72-00f6721ee18b.json @@ -0,0 +1,15 @@ +{ + "id": "1f81445a-404f-48ea-bd72-00f6721ee18b", + "name": "employees", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/employees.parquet", + "size_bytes": 1424, + "created_at": "2026-03-27T11:55:55.013996293Z" + } + ], + "created_at": "2026-03-27T11:55:55.014010258Z", + "updated_at": "2026-03-27T11:55:55.014010258Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/6c8dabf2-a6b3-4e23-b7d7-dc3b37a5e1a8.json b/data/_catalog/manifests/6c8dabf2-a6b3-4e23-b7d7-dc3b37a5e1a8.json new file mode 100644 index 0000000..f140ce9 --- /dev/null +++ b/data/_catalog/manifests/6c8dabf2-a6b3-4e23-b7d7-dc3b37a5e1a8.json @@ -0,0 +1,15 @@ +{ + "id": "6c8dabf2-a6b3-4e23-b7d7-dc3b37a5e1a8", + "name": "events", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/events.parquet", + "size_bytes": 1127, + "created_at": "2026-03-27T11:55:55.017003417Z" + } + ], + "created_at": "2026-03-27T11:55:55.017011224Z", + "updated_at": "2026-03-27T11:55:55.017011224Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/f5281277-afe9-456b-b5fa-d667c2b0f41f.json b/data/_catalog/manifests/f5281277-afe9-456b-b5fa-d667c2b0f41f.json new file mode 100644 index 0000000..cd2a93e --- /dev/null +++ b/data/_catalog/manifests/f5281277-afe9-456b-b5fa-d667c2b0f41f.json @@ -0,0 +1,15 @@ +{ + "id": "f5281277-afe9-456b-b5fa-d667c2b0f41f", + "name": "products", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/products.parquet", + "size_bytes": 1341, + "created_at": "2026-03-27T11:55:55.019096056Z" + } + ], + "created_at": "2026-03-27T11:55:55.019098965Z", + "updated_at": "2026-03-27T11:55:55.019098965Z" +} \ No newline at end of file diff --git a/data/datasets/employees.parquet b/data/datasets/employees.parquet new file mode 100644 index 0000000..e830c57 Binary files /dev/null and b/data/datasets/employees.parquet differ diff --git a/data/datasets/events.parquet b/data/datasets/events.parquet new file mode 100644 index 0000000..c9d827d Binary files /dev/null and b/data/datasets/events.parquet differ diff --git a/data/datasets/products.parquet b/data/datasets/products.parquet new file mode 100644 index 0000000..0006892 Binary files /dev/null and b/data/datasets/products.parquet differ