UI: full-stack test coverage with tabs for Query, Storage, AI, Status

- Query tab: SQL editor with results table (existing)
- Storage tab: list objects, register datasets pointing at storage keys
- AI tab: embed (nomic-embed-text), generate (qwen2.5), rerank with scored results
- Status tab: health checks for all 5 services + functional tests (embed, generate, SQL)
- nginx: added /lakehouse/ and API proxy paths to devop.live config
- Loaded 3 sample datasets: employees, events, products
- Fixed Rust 2024 reserved keyword `gen`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-27 06:56:18 -05:00
parent 01373c0e45
commit 387ce0074c
8 changed files with 870 additions and 153 deletions

View File

@ -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); }

View File

@ -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<ObjectRef>,
objects: Vec<ObjRef>,
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<Vec<f64>>,
model: String,
dimensions: usize,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct GenerateResponse {
text: String,
model: String,
tokens_evaluated: Option<u64>,
tokens_generated: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct RerankResult {
index: usize,
text: String,
score: f64,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct RerankResponse {
results: Vec<RerankResult>,
model: 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())?;
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<QueryResponse, String> {
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<String, String> {
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<Vec<String>, 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<u8>) -> Result<String, String> {
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<Dataset, String> {
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<String>) -> Result<EmbedResponse, String> {
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<GenerateResponse, String> {
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<String>) -> Result<RerankResponse, String> {
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<String, String> {
// 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::<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>);
@ -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<String>) -> 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::<Result<QueryResponse, String>>);
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::<String>::new);
let mut obj_loading = use_signal(|| false);
let mut obj_error = use_signal(|| None::<String>);
let mut reg_name = use_signal(|| String::new());
let mut reg_key = use_signal(|| String::new());
let mut reg_status = use_signal(|| None::<Result<String, String>>);
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::<Result<EmbedResponse, String>>);
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::<Result<GenerateResponse, String>>);
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::<Result<RerankResponse, String>>);
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<String> = 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<String> = 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<String> = 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<String, String>)>::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::<String>(), 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))} }
}
}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.