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:
parent
01373c0e45
commit
387ce0074c
@ -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); }
|
||||
|
||||
@ -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))} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
BIN
data/datasets/employees.parquet
Normal file
BIN
data/datasets/employees.parquet
Normal file
Binary file not shown.
BIN
data/datasets/events.parquet
Normal file
BIN
data/datasets/events.parquet
Normal file
Binary file not shown.
BIN
data/datasets/products.parquet
Normal file
BIN
data/datasets/products.parquet
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user