From b37e171e10f7deb1a5fb8c446e627d17f6be3d13 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Mar 2026 07:24:51 -0500 Subject: [PATCH] UI redesign: Ask, Explore, SQL, System tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ask: natural language → AI generates SQL → DataFusion executes → results Shows the AI-over-data-lake story: schema introspection → LLM → query - Explore: click dataset → schema + preview + AI-generated summary - SQL: raw DataFusion SQL editor with Ctrl+Enter - System: health grid testing all 5 services + embeddings + generation - Example prompts for quick demo - Dark theme with accent styling Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ui/Dioxus.toml | 1 + crates/ui/assets/style.css | 443 ++------ crates/ui/src/main.rs | 958 ++++++++---------- .../50d402a0-6fb4-4849-ac75-3541b8530aba.json | 15 + .../fc046131-8f44-428a-af90-ebf6347d4cc6.json | 15 + 5 files changed, 544 insertions(+), 888 deletions(-) create mode 100644 data/_catalog/manifests/50d402a0-6fb4-4849-ac75-3541b8530aba.json create mode 100644 data/_catalog/manifests/fc046131-8f44-428a-af90-ebf6347d4cc6.json diff --git a/crates/ui/Dioxus.toml b/crates/ui/Dioxus.toml index 47aedcf..3a3c95d 100644 --- a/crates/ui/Dioxus.toml +++ b/crates/ui/Dioxus.toml @@ -1,5 +1,6 @@ [application] name = "lakehouse" +asset_dir = "assets" [web.app] title = "Lakehouse" diff --git a/crates/ui/assets/style.css b/crates/ui/assets/style.css index b744d2b..8e56834 100644 --- a/crates/ui/assets/style.css +++ b/crates/ui/assets/style.css @@ -1,383 +1,164 @@ :root { --bg: #0a0a0f; --surface: #12121a; + --surface2: #1a1a28; --border: #2a2a3a; --text: #e0e0e8; --text-dim: #888898; --accent: #6c5ce7; --accent-dim: #4a3db8; + --accent-glow: rgba(108, 92, 231, 0.15); --success: #00d2a0; --error: #ff6b6b; + --warn: #ffa94d; --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; } -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; -} +.app { display: grid; grid-template-rows: 52px 1fr; height: 100vh; overflow: hidden; } /* Header */ .header { - display: flex; - align-items: center; - padding: 0 20px; - background: var(--surface); - border-bottom: 1px solid var(--border); - gap: 24px; -} - -.header h1 { - font-size: 15px; - font-weight: 600; - letter-spacing: 0.05em; - color: var(--accent); + display: flex; align-items: center; padding: 0 24px; gap: 32px; + background: var(--surface); border-bottom: 1px solid var(--border); } +.header h1 { font-size: 16px; font-weight: 700; letter-spacing: 0.08em; color: var(--accent); } +.header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } +.ds-count { font-size: 11px; color: var(--text-dim); } /* 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; + background: none; border: none; color: var(--text-dim); font-family: var(--mono); + font-size: 13px; padding: 8px 18px; cursor: pointer; border-bottom: 2px solid transparent; + transition: all 0.15s; font-weight: 500; } - .tab:hover { color: var(--text); } -.tab.active { - color: var(--accent); - border-bottom-color: var(--accent); -} - -/* Main layout */ -.main { - display: grid; - grid-template-columns: 240px 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); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } /* Buttons */ .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; + background: var(--accent); color: white; border: none; padding: 10px 20px; + border-radius: 6px; font-family: var(--mono); font-size: 13px; cursor: pointer; + font-weight: 600; white-space: nowrap; transition: all 0.15s; } - -.btn:hover { background: var(--accent-dim); } -.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn:hover { background: var(--accent-dim); transform: translateY(-1px); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } +.btn-ask { padding: 12px 28px; font-size: 14px; } .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; + background: none; border: 1px solid var(--border); color: var(--text-dim); + padding: 4px 10px; border-radius: 4px; font-family: var(--mono); font-size: 12px; cursor: pointer; } - .refresh-btn:hover { border-color: var(--accent); color: var(--accent); } -/* 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); } +/* Content */ +.content-full { overflow-y: auto; flex: 1; } +.panel { padding: 32px; max-width: 1000px; margin: 0 auto; } /* States */ -.loading { color: var(--accent); padding: 20px; text-align: center; } +.loading { color: var(--accent); padding: 24px; text-align: center; } +.error { color: var(--error); background: rgba(255,107,107,0.08); border: 1px solid rgba(255,107,107,0.2); padding: 14px 18px; border-radius: 6px; font-size: 12px; margin-top: 12px; } +.error-inline { color: var(--error); font-size: 12px; } +.empty { color: var(--text-dim); padding: 48px 24px; text-align: center; font-size: 13px; } +.empty-sm { color: var(--text-dim); padding: 16px; text-align: center; font-size: 12px; } -.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; +/* === ASK PANEL === */ +.ask-panel { max-width: 860px; } +.ask-hero { margin-bottom: 28px; } +.ask-hero h2 { font-size: 28px; font-weight: 700; margin-bottom: 6px; } +.subtitle { color: var(--text-dim); font-size: 14px; } + +.ask-input-row { display: flex; gap: 10px; margin-bottom: 12px; } +.ask-input { + flex: 1; background: var(--surface); border: 2px solid var(--border); color: var(--text); + font-family: var(--mono); font-size: 15px; padding: 14px 18px; border-radius: 8px; outline: none; } +.ask-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } +.ask-input::placeholder { color: var(--text-dim); } -.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; +.ask-examples { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 24px; font-size: 11px; color: var(--text-dim); align-items: center; } +.example-btn { + background: var(--surface2); border: 1px solid var(--border); color: var(--text-dim); + font-family: var(--mono); font-size: 11px; padding: 4px 10px; border-radius: 4px; cursor: pointer; } +.example-btn:hover { border-color: var(--accent); color: var(--accent); } -.empty { color: var(--text-dim); padding: 40px 20px; text-align: center; font-size: 12px; } - -/* Panels */ -.panel { - padding: 20px; - overflow-y: auto; - flex: 1; +.generated-sql { + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + padding: 16px; margin-bottom: 20px; } +.sql-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); display: block; margin-bottom: 8px; } +.sql-code { font-size: 13px; color: var(--accent); white-space: pre-wrap; word-break: break-all; } -.panel-section { - margin-bottom: 28px; - padding-bottom: 20px; - border-bottom: 1px solid var(--border); +/* === EXPLORE PANEL === */ +.explore-panel { max-width: 1100px; } +.explore-grid { display: grid; grid-template-columns: 260px 1fr; gap: 24px; min-height: 500px; } +.ds-cards h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 12px; } + +.ds-card { + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + padding: 14px; margin-bottom: 8px; cursor: pointer; transition: all 0.15s; } +.ds-card:hover { border-color: var(--accent); } +.ds-card.active { border-color: var(--accent); background: var(--accent-glow); } +.ds-card-name { font-weight: 600; font-size: 14px; } +.ds-card-meta { font-size: 11px; color: var(--text-dim); margin-top: 4px; } -.panel-section:last-child { border-bottom: none; } +.ds-detail h3 { font-size: 20px; font-weight: 700; margin-bottom: 16px; } -.panel-section h3 { - font-size: 14px; - font-weight: 600; - color: var(--accent); - margin-bottom: 6px; +.summary-box { + background: var(--accent-glow); border: 1px solid rgba(108,92,231,0.3); border-radius: 8px; + padding: 16px; margin-bottom: 20px; } +.summary-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); display: block; margin-bottom: 8px; font-weight: 600; } +.summary-text { font-size: 13px; line-height: 1.7; } -.hint { - font-size: 11px; - color: var(--text-dim); - margin-bottom: 12px; +.section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); display: block; margin-bottom: 10px; font-weight: 600; } +.schema-box, .preview-box { margin-bottom: 24px; } + +/* === SQL PANEL === */ +.sql-editor { margin-bottom: 16px; } +.sql-textarea { + width: 100%; background: var(--surface); border: 1px solid var(--border); color: var(--text); + font-family: var(--mono); font-size: 14px; padding: 16px; border-radius: 8px; + resize: vertical; min-height: 120px; outline: none; margin-bottom: 10px; } +.sql-textarea:focus { border-color: var(--accent); } +.sql-actions { display: flex; gap: 8px; } +.results-area { margin-top: 8px; } -/* Forms */ -.form-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; +/* === STATUS PANEL === */ +.status-hero { margin-bottom: 28px; } +.status-hero h2 { font-size: 24px; font-weight: 700; margin-bottom: 6px; } +.status-hero .btn { margin-top: 16px; } + +.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.check-card { + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + padding: 16px; display: flex; gap: 14px; align-items: flex-start; } +.check-card.pass { border-left: 3px solid var(--success); } +.check-card.fail { border-left: 3px solid var(--error); } +.check-icon { font-size: 18px; font-weight: 700; min-width: 24px; text-align: center; } +.pass .check-icon { color: var(--success); } +.fail .check-icon { color: var(--error); } +.check-info { flex: 1; min-width: 0; } +.check-name { font-weight: 600; font-size: 13px; } +.check-desc { font-size: 11px; color: var(--text-dim); margin: 2px 0; } +.check-msg { font-size: 11px; color: var(--text-dim); word-break: break-word; overflow: hidden; text-overflow: ellipsis; max-height: 3em; } +.pass .check-msg { color: var(--success); } +.fail .check-msg { color: var(--error); } -.form-row label { - font-size: 11px; - color: var(--text-dim); - min-width: 80px; +/* Results table */ +.results-info { font-size: 11px; color: var(--text-dim); margin-bottom: 8px; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th { + text-align: left; padding: 10px 14px; background: var(--surface2); 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; } - -.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; -} - -.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); - font-family: var(--mono); - font-size: 12px; - padding: 10px; - border-radius: 4px; - resize: vertical; - min-height: 60px; - outline: none; - margin-bottom: 8px; -} - -.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); } +td { padding: 8px 14px; border-bottom: 1px solid var(--border); } +tr:hover td { background: var(--accent-glow); } diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs index be6236d..4371d4e 100644 --- a/crates/ui/src/main.rs +++ b/crates/ui/src/main.rs @@ -3,14 +3,8 @@ use serde::{Deserialize, Serialize}; fn api_base() -> String { if let Some(window) = web_sys::window() { - if let Ok(origin) = window.location().origin() { - if origin.contains("localhost") || origin.contains("127.0.0.1") { - if let Ok(hostname) = window.location().hostname() { - return format!("http://{}:3100", hostname); - } - } - // Production: API proxied under /lakehouse/api/ - return format!("{}/lakehouse/api", origin); + if let Ok(hostname) = window.location().hostname() { + return format!("http://{}:3100", hostname); } } "http://localhost:3100".to_string() @@ -53,13 +47,6 @@ 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, @@ -69,16 +56,10 @@ struct GenerateResponse { } #[derive(Debug, Clone, Deserialize, PartialEq)] -struct RerankResult { - index: usize, - text: String, - score: f64, -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -struct RerankResponse { - results: Vec, +struct EmbedResponse { + embeddings: Vec>, model: String, + dimensions: usize, } // --- API Calls --- @@ -89,7 +70,7 @@ async fn fetch_datasets() -> Result, String> { resp.json().await.map_err(|e| e.to_string()) } -async fn execute_query(sql: &str) -> Result { +async fn run_sql(sql: &str) -> Result { let client = reqwest::Client::new(); let resp = client.post(&format!("{}/query/sql", api_base())) .json(&serde_json::json!({"sql": sql})) @@ -100,38 +81,10 @@ async fn execute_query(sql: &str) -> Result { 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 { +async fn ai_generate(prompt: &str, max_tokens: u32) -> 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}] - })) + let resp = client.post(&format!("{}/ai/generate", api_base())) + .json(&serde_json::json!({"prompt": prompt, "max_tokens": max_tokens, "temperature": 0.2})) .send().await.map_err(|e| e.to_string())?; if !resp.status().is_success() { return Err(resp.text().await.unwrap_or_default()); @@ -150,182 +103,369 @@ async fn ai_embed(texts: Vec) -> Result { 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 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 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()); +/// Get schema context for all datasets (used for AI SQL generation) +async fn get_schema_context(datasets: &[Dataset]) -> String { + let mut ctx = String::from("Available tables:\n\n"); + for ds in datasets { + let desc = run_sql(&format!("DESCRIBE {}", ds.name)).await; + match desc { + Ok(resp) => { + ctx.push_str(&format!("TABLE: {}\n", ds.name)); + if let Some(rows) = resp.rows.as_array() { + for row in rows { + let col = row.get("column_name").and_then(|v| v.as_str()).unwrap_or("?"); + let dt = row.get("data_type").and_then(|v| v.as_str()).unwrap_or("?"); + let nullable = row.get("is_nullable").and_then(|v| v.as_str()).unwrap_or("?"); + ctx.push_str(&format!(" - {} ({}, nullable={})\n", col, dt, nullable)); + } + } + ctx.push('\n'); + } + Err(_) => { + ctx.push_str(&format!("TABLE: {} (schema unavailable)\n\n", ds.name)); + } + } } - 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) + ctx } // --- Tabs --- #[derive(Clone, PartialEq)] enum Tab { - Query, - Storage, - Ai, + Ask, + Explore, + Sql, Status, } -// --- Components --- +// --- App --- #[component] fn App() -> Element { - let mut active_tab = use_signal(|| Tab::Query); + let mut active_tab = use_signal(|| Tab::Ask); let mut datasets = use_signal(Vec::::new); - let mut selected = use_signal(|| None::); let mut ds_loading = use_signal(|| true); - let mut ds_error = use_signal(|| None::); - // 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)), + if let Ok(ds) = fetch_datasets().await { + datasets.set(ds); } ds_loading.set(false); }); }); - let refresh_datasets = move |_| { + let refresh = 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)), + if let Ok(ds) = fetch_datasets().await { + datasets.set(ds); } ds_loading.set(false); }); }; - let mut select_dataset = move |name: String| { - selected.set(Some(name.clone())); - active_tab.set(Tab::Query); - }; - rsx! { div { class: "app", - // Header div { class: "header", h1 { "LAKEHOUSE" } div { class: "tabs", button { - class: if *active_tab.read() == Tab::Query { "tab active" } else { "tab" }, - onclick: move |_| active_tab.set(Tab::Query), - "Query" + class: if *active_tab.read() == Tab::Ask { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Ask), + "Ask" } button { - class: if *active_tab.read() == Tab::Storage { "tab active" } else { "tab" }, - onclick: move |_| active_tab.set(Tab::Storage), - "Storage" + class: if *active_tab.read() == Tab::Explore { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Explore), + "Explore" } button { - class: if *active_tab.read() == Tab::Ai { "tab active" } else { "tab" }, - onclick: move |_| active_tab.set(Tab::Ai), - "AI" + class: if *active_tab.read() == Tab::Sql { "tab active" } else { "tab" }, + onclick: move |_| active_tab.set(Tab::Sql), + "SQL" } button { class: if *active_tab.read() == Tab::Status { "tab active" } else { "tab" }, onclick: move |_| active_tab.set(Tab::Status), - "Status" + "System" } } + div { class: "header-right", + span { class: "ds-count", + {if *ds_loading.read() { "...".to_string() } else { format!("{} datasets", datasets.read().len()) }} + } + button { class: "refresh-btn", onclick: refresh, "↻" } + } } - // 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" } - } 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)" } - } + div { class: "content-full", + match *active_tab.read() { + Tab::Ask => rsx! { AskPanel { datasets: datasets.read().clone() } }, + Tab::Explore => rsx! { ExplorePanel { datasets: datasets.read().clone() } }, + Tab::Sql => rsx! { SqlPanel {} }, + Tab::Status => rsx! { StatusPanel {} }, + } + } + } + } +} + +// === ASK — Natural language → SQL → Results === + +#[component] +fn AskPanel(datasets: Vec) -> Element { + let mut question = use_signal(|| String::new()); + let mut generated_sql = use_signal(|| None::); + let mut result = use_signal(|| None::>); + let mut thinking = use_signal(|| false); + let mut step = use_signal(|| String::new()); + + let datasets_clone = datasets.clone(); + + let ask = move |_| { + let q = question.read().clone(); + if q.trim().is_empty() { return; } + let ds = datasets_clone.clone(); + spawn(async move { + thinking.set(true); + generated_sql.set(None); + result.set(None); + + // Step 1: Get schema context + step.set("reading schemas...".into()); + let schema_ctx = get_schema_context(&ds).await; + + // Step 2: Generate SQL + step.set("writing SQL...".into()); + let prompt = format!( + "You are a SQL assistant for a data lakehouse using Apache DataFusion (PostgreSQL-compatible SQL).\n\n\ + {schema_ctx}\n\ + User question: {q}\n\n\ + Write a SQL query that answers this question. Output ONLY the SQL query, nothing else. No markdown, no explanation, no backticks." + ); + + match ai_generate(&prompt, 512).await { + Ok(resp) => { + let sql = resp.text.trim().to_string(); + generated_sql.set(Some(sql.clone())); + + // Step 3: Execute + step.set("running query...".into()); + let query_result = run_sql(&sql).await; + result.set(Some(query_result)); + } + Err(e) => { + result.set(Some(Err(format!("AI error: {e}")))); + } + } + + step.set(String::new()); + thinking.set(false); + }); + }; + + rsx! { + div { class: "panel ask-panel", + div { class: "ask-hero", + h2 { "Ask your data anything" } + p { class: "subtitle", "Natural language → SQL → Results. Powered by local AI." } + } + + div { class: "ask-input-row", + input { + class: "ask-input", + value: "{question}", + placeholder: "e.g. Which department has the highest average salary?", + oninput: move |e| question.set(e.value()), + onkeydown: move |e| { + if e.key() == Key::Enter { + let q = question.read().clone(); + if q.trim().is_empty() { return; } + let ds = datasets.clone(); + spawn(async move { + thinking.set(true); + generated_sql.set(None); + result.set(None); + step.set("reading schemas...".into()); + let schema_ctx = get_schema_context(&ds).await; + step.set("writing SQL...".into()); + let prompt = format!( + "You are a SQL assistant for a data lakehouse using Apache DataFusion (PostgreSQL-compatible SQL).\n\n\ + {schema_ctx}\n\ + User question: {q}\n\n\ + Write a SQL query that answers this question. Output ONLY the SQL query, nothing else. No markdown, no explanation, no backticks." + ); + match ai_generate(&prompt, 512).await { + Ok(resp) => { + let sql = resp.text.trim().to_string(); + generated_sql.set(Some(sql.clone())); + step.set("running query...".into()); + let query_result = run_sql(&sql).await; + result.set(Some(query_result)); } + Err(e) => { + result.set(Some(Err(format!("AI error: {e}")))); + } + } + step.set(String::new()); + thinking.set(false); + }); + } + }, + } + button { + class: "btn btn-ask", + disabled: *thinking.read(), + onclick: ask, + {if *thinking.read() { step.read().clone() } else { "Ask".to_string() }} + } + } + + div { class: "ask-examples", + "Try: " + button { class: "example-btn", onclick: move |_| question.set("Which department has the highest average salary?".into()), "highest avg salary by dept" } + button { class: "example-btn", onclick: move |_| question.set("Show me the top 3 most expensive products".into()), "top 3 expensive products" } + button { class: "example-btn", onclick: move |_| question.set("How many events per action type?".into()), "events by action" } + button { class: "example-btn", onclick: move |_| question.set("List all employees who earn more than 90000".into()), "employees > 90k" } + } + + if let Some(sql) = generated_sql.read().as_ref() { + div { class: "generated-sql", + span { class: "sql-label", "Generated SQL" } + pre { class: "sql-code", "{sql}" } + } + } + + match result.read().as_ref() { + None => rsx! {}, + Some(Err(e)) => rsx! { div { class: "error", "{e}" } }, + Some(Ok(resp)) => rsx! { ResultsTable { response: resp.clone() } }, + } + } + } +} + +// === EXPLORE — Dataset overview + AI summary === + +#[component] +fn ExplorePanel(datasets: Vec) -> Element { + let mut selected = use_signal(|| None::); + let mut schema_info = use_signal(|| None::>); + let mut preview = use_signal(|| None::>); + let mut summary = use_signal(|| None::>); + let mut loading = use_signal(|| false); + + let mut select_ds = move |name: String| { + selected.set(Some(name.clone())); + spawn(async move { + loading.set(true); + schema_info.set(None); + preview.set(None); + summary.set(None); + + // Get schema + let desc = run_sql(&format!("DESCRIBE {name}")).await; + schema_info.set(Some(desc)); + + // Get preview + let prev = run_sql(&format!("SELECT * FROM {name} LIMIT 5")).await; + + // Generate AI summary + if let Ok(ref data) = prev { + let data_json = serde_json::to_string(&data.rows).unwrap_or_default(); + let cols: Vec = data.columns.iter() + .map(|c| format!("{} ({})", c.name, c.data_type)) + .collect(); + let prompt = format!( + "You are a data analyst. Describe this dataset in 2-3 sentences. Be specific about what the data contains and what insights it could provide.\n\n\ + Table: {name}\n\ + Columns: {}\n\ + Sample data (first 5 rows): {data_json}\n\n\ + Description:", + cols.join(", ") + ); + let summ = ai_generate(&prompt, 256).await.map(|r| r.text); + summary.set(Some(summ)); + } + + preview.set(Some(prev)); + loading.set(false); + }); + }; + + rsx! { + div { class: "panel explore-panel", + div { class: "explore-grid", + // Dataset cards + div { class: "ds-cards", + h3 { "Datasets" } + if datasets.is_empty() { + div { class: "empty", "No datasets registered" } + } + for ds in datasets.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(); + let total_bytes: u64 = ds.objects.iter().map(|o| o.size_bytes).sum(); + rsx! { + div { + class: if is_active { "ds-card active" } else { "ds-card" }, + onclick: move |_| select_ds(name.clone()), + div { class: "ds-card-name", "{name2}" } + div { class: "ds-card-meta", "{obj_count} file(s) · {total_bytes} bytes" } } } } } } - // Content — tab panels - div { class: "content", - match *active_tab.read() { - Tab::Query => rsx! { QueryPanel { selected: selected.read().clone() } }, - Tab::Storage => rsx! { StoragePanel {} }, - Tab::Ai => rsx! { AiPanel {} }, - Tab::Status => rsx! { StatusPanel {} }, + // Detail view + div { class: "ds-detail", + if *loading.read() { + div { class: "loading", "analyzing dataset..." } + } else if selected.read().is_none() { + div { class: "empty", "select a dataset to explore" } + } else { + if let Some(ref name) = *selected.read() { + h3 { "{name}" } + } + + // AI Summary + if let Some(result) = summary.read().as_ref() { + div { class: "summary-box", + span { class: "summary-label", "AI Summary" } + match result { + Ok(text) => rsx! { p { class: "summary-text", "{text}" } }, + Err(e) => rsx! { p { class: "error-inline", "{e}" } }, + } + } + } + + // Schema + if let Some(Ok(schema)) = schema_info.read().as_ref() { + div { class: "schema-box", + span { class: "section-label", "Schema" } + ResultsTable { response: schema.clone() } + } + } + + // Preview + if let Some(Ok(prev)) = preview.read().as_ref() { + div { class: "preview-box", + span { class: "section-label", "Preview (5 rows)" } + ResultsTable { response: prev.clone() } + } + } } } } @@ -333,65 +473,57 @@ fn App() -> Element { } } -// === QUERY PANEL === +// === SQL — Raw SQL editor === #[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::>); +fn SqlPanel() -> Element { + let mut query_text = use_signal(|| String::from("SELECT * FROM employees LIMIT 10")); + let mut result = use_signal(|| None::>); let mut loading = use_signal(|| false); - let run_query = move |_| { + let run = 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)); + result.set(Some(run_sql(&sql).await)); 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); - }); + div { class: "panel", + div { class: "sql-editor", + textarea { + class: "sql-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); + result.set(Some(run_sql(&sql).await)); + loading.set(false); + }); + } } + }, + } + div { class: "sql-actions", + button { + class: "btn", + disabled: *loading.read(), + onclick: run, + if *loading.read() { "running..." } else { "Run (Ctrl+Enter)" } } - }, + } } - 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" } }, + div { class: "results-area", + match result.read().as_ref() { + None => rsx! { div { class: "empty", "run a query to see results" } }, Some(Err(e)) => rsx! { div { class: "error", "{e}" } }, Some(Ok(resp)) => rsx! { ResultsTable { response: resp.clone() } }, } @@ -400,344 +532,48 @@ fn QueryPanel(selected: Option) -> Element { } } -// === 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 === +// === STATUS — System health === #[component] fn StatusPanel() -> Element { - let mut results = use_signal(Vec::<(String, Result)>::new); + let mut results = use_signal(Vec::<(String, 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)); + checks.push(("Gateway".into(), "HTTP ingress".into(), r)); - // Storage health let r = fetch_health("/storage/health").await; - checks.push(("Storage /storage/health".to_string(), r)); + checks.push(("Storage".into(), "Object store (Parquet files)".into(), r)); - // Catalog health let r = fetch_health("/catalog/health").await; - checks.push(("Catalog /catalog/health".to_string(), r)); + checks.push(("Catalog".into(), "Dataset registry".into(), r)); - // Query health let r = fetch_health("/query/health").await; - checks.push(("Query /query/health".to_string(), r)); + checks.push(("Query Engine".into(), "DataFusion SQL".into(), r)); - // AI health let r = fetch_health("/ai/health").await; - checks.push(("AI Bridge /ai/health".to_string(), r)); + checks.push(("AI Bridge".into(), "Ollama sidecar".into(), r)); - // Storage: list objects - let r = list_objects("").await.map(|keys| format!("{} objects", keys.len())); - checks.push(("Storage: list objects".to_string(), r)); + let r = fetch_datasets().await.map(|ds| format!("{} datasets registered", ds.len())); + checks.push(("Catalog Data".into(), "Dataset count".into(), r)); - // Catalog: list datasets - let r = fetch_datasets().await.map(|ds| format!("{} datasets", ds.len())); - checks.push(("Catalog: list datasets".to_string(), r)); + let r = run_sql("SELECT 1 + 1 as result").await + .map(|q| format!("1+1 = {} ({} row)", q.rows.as_array().and_then(|a| a.first()).and_then(|r| r.get("result")).map(|v| v.to_string()).unwrap_or("?".into()), q.row_count)); + checks.push(("SQL Execution".into(), "DataFusion compute".into(), 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)); + let r = ai_embed(vec!["health check".into()]).await + .map(|e| format!("{}d vector from {}", e.dimensions, e.model)); + checks.push(("Embeddings".into(), "nomic-embed-text via Ollama".into(), 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)); + let r = ai_generate("Say OK", 8).await + .map(|g| format!("\"{}\" via {}", g.text.trim(), g.model)); + checks.push(("Generation".into(), "LLM via Ollama".into(), r)); results.set(checks); checking.set(false); @@ -746,29 +582,35 @@ fn StatusPanel() -> Element { 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" } + div { class: "status-hero", + h2 { "System Status" } + p { class: "subtitle", "Verify every layer: Rust gateway → object storage → DataFusion → Ollama" } button { - class: "btn", + class: "btn btn-ask", 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() { + div { class: "check-grid", + for (name, desc, 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)), + let desc = desc.clone(); + let (class, icon, text) = match result { + Ok(msg) => ("check-card pass", "✓", msg.clone()), + Err(e) => ("check-card fail", "✗", e.clone()), }; rsx! { - div { class: "check-row {class}", - span { class: "check-name", "{name}" } - span { class: "check-result", "{text}" } + div { class: "{class}", + div { class: "check-icon", "{icon}" } + div { class: "check-info", + div { class: "check-name", "{name}" } + div { class: "check-desc", "{desc}" } + div { class: "check-msg", "{text}" } + } } } } @@ -779,7 +621,7 @@ fn StatusPanel() -> Element { } } -// === RESULTS TABLE === +// === Results Table === #[component] fn ResultsTable(response: QueryResponse) -> Element { @@ -789,21 +631,23 @@ fn ResultsTable(response: QueryResponse) -> Element { "{response.row_count} row(s) · {response.columns.len()} column(s)" } if response.row_count == 0 { - div { class: "empty", "query returned no rows" } + div { class: "empty-sm", "no rows returned" } } 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() { + div { class: "table-wrap", + table { + thead { tr { for col in response.columns.iter() { - td { {format_cell(row.get(&col.name))} } + 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))} } + } } } } diff --git a/data/_catalog/manifests/50d402a0-6fb4-4849-ac75-3541b8530aba.json b/data/_catalog/manifests/50d402a0-6fb4-4849-ac75-3541b8530aba.json new file mode 100644 index 0000000..9e89f03 --- /dev/null +++ b/data/_catalog/manifests/50d402a0-6fb4-4849-ac75-3541b8530aba.json @@ -0,0 +1,15 @@ +{ + "id": "50d402a0-6fb4-4849-ac75-3541b8530aba", + "name": "u5", + "schema_fingerprint": "from-ui", + "objects": [ + { + "bucket": "data", + "key": "datasets/events.parquet", + "size_bytes": 0, + "created_at": "2026-03-27T12:16:57.520054490Z" + } + ], + "created_at": "2026-03-27T12:16:57.520066991Z", + "updated_at": "2026-03-27T12:16:57.520066991Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/fc046131-8f44-428a-af90-ebf6347d4cc6.json b/data/_catalog/manifests/fc046131-8f44-428a-af90-ebf6347d4cc6.json new file mode 100644 index 0000000..265200f --- /dev/null +++ b/data/_catalog/manifests/fc046131-8f44-428a-af90-ebf6347d4cc6.json @@ -0,0 +1,15 @@ +{ + "id": "fc046131-8f44-428a-af90-ebf6347d4cc6", + "name": "u", + "schema_fingerprint": "from-ui", + "objects": [ + { + "bucket": "data", + "key": "datasets/employees.parquet", + "size_bytes": 0, + "created_at": "2026-03-27T12:16:51.462058823Z" + } + ], + "created_at": "2026-03-27T12:16:51.462071311Z", + "updated_at": "2026-03-27T12:16:51.462071311Z" +} \ No newline at end of file