UI: Dashboard + Ingest tabs showing full system progression

- Dashboard: live stats (datasets, rows, embeddings, HNSW, tools, cache)
  Architecture overview (6 capability areas)
  Build progression timeline (all 17 phases listed)
- Ingest tab: Postgres table browser + import, file upload info, inbox watcher
- System tab: existing health checks
- Starts on Dashboard for immediate overview
- No futures::executor in WASM — all async/await

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-27 20:22:36 -05:00
parent 9992b5f135
commit 399fc81ab5
2 changed files with 349 additions and 1 deletions

View File

@ -162,3 +162,46 @@ th {
}
td { padding: 8px 14px; border-bottom: 1px solid var(--border); }
tr:hover td { background: var(--accent-glow); }
/* Dashboard */
.dashboard-hero { margin-bottom: 28px; }
.dashboard-hero h2 { font-size: 32px; font-weight: 700; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px; }
.stat-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 20px 16px; text-align: center;
}
.stat-card.accent { border-color: var(--accent); background: var(--accent-glow); }
.stat-value { font-size: 28px; font-weight: 700; color: var(--text); }
.stat-card.accent .stat-value { color: var(--accent); }
.stat-label { font-size: 11px; color: var(--text-dim); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
.architecture-section, .phases-section { margin-bottom: 28px; }
.architecture-section h3, .phases-section h3 { font-size: 16px; font-weight: 600; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
.arch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
.arch-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 14px; border-left: 3px solid var(--accent);
}
.arch-title { font-weight: 600; font-size: 13px; margin-bottom: 4px; color: var(--accent); }
.arch-items { font-size: 11px; color: var(--text-dim); line-height: 1.6; }
.phase-list { display: flex; flex-direction: column; gap: 2px; }
.phase-item {
display: grid; grid-template-columns: 40px 180px 1fr; gap: 12px; align-items: center;
padding: 8px 12px; border-radius: 6px; font-size: 12px;
background: var(--surface); border-left: 3px solid var(--success);
}
.phase-num { font-weight: 700; color: var(--accent); text-align: center; }
.phase-name { font-weight: 600; }
.phase-detail { color: var(--text-dim); }
/* Ingest */
.table-list { margin-top: 12px; }
.table-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px;
}
.table-item:hover { background: var(--accent-glow); }

View File

@ -138,9 +138,11 @@ async fn get_schema_context(datasets: &[Dataset]) -> String {
// --- Tabs ---
#[derive(Clone, PartialEq)]
enum Tab {
Dashboard,
Ask,
Explore,
Sql,
Ingest,
Status,
}
@ -148,7 +150,7 @@ enum Tab {
#[component]
fn App() -> Element {
let mut active_tab = use_signal(|| Tab::Ask);
let mut active_tab = use_signal(|| Tab::Dashboard);
let mut datasets = use_signal(Vec::<Dataset>::new);
let mut ds_loading = use_signal(|| true);
@ -177,6 +179,11 @@ fn App() -> Element {
div { class: "header",
h1 { "LAKEHOUSE" }
div { class: "tabs",
button {
class: if *active_tab.read() == Tab::Dashboard { "tab active" } else { "tab" },
onclick: move |_| active_tab.set(Tab::Dashboard),
"Dashboard"
}
button {
class: if *active_tab.read() == Tab::Ask { "tab active" } else { "tab" },
onclick: move |_| active_tab.set(Tab::Ask),
@ -192,6 +199,11 @@ fn App() -> Element {
onclick: move |_| active_tab.set(Tab::Sql),
"SQL"
}
button {
class: if *active_tab.read() == Tab::Ingest { "tab active" } else { "tab" },
onclick: move |_| active_tab.set(Tab::Ingest),
"Ingest"
}
button {
class: if *active_tab.read() == Tab::Status { "tab active" } else { "tab" },
onclick: move |_| active_tab.set(Tab::Status),
@ -208,9 +220,11 @@ fn App() -> Element {
div { class: "content-full",
match *active_tab.read() {
Tab::Dashboard => rsx! { DashboardPanel {} },
Tab::Ask => rsx! { AskPanel { datasets: datasets.read().clone() } },
Tab::Explore => rsx! { ExplorePanel { datasets: datasets.read().clone() } },
Tab::Sql => rsx! { SqlPanel {} },
Tab::Ingest => rsx! { IngestPanel {} },
Tab::Status => rsx! { StatusPanel {} },
}
}
@ -532,6 +546,297 @@ fn SqlPanel() -> Element {
}
}
// === DASHBOARD — System overview ===
#[component]
fn DashboardPanel() -> Element {
let mut stats = use_signal(|| None::<serde_json::Value>);
let mut loading = use_signal(|| false);
let do_load = move || {
spawn(async move {
loading.set(true);
let client = reqwest::Client::new();
let datasets = fetch_datasets().await.ok().map(|d| d.len()).unwrap_or(0);
let row_count = run_sql("SELECT (SELECT COUNT(*) FROM candidates) + (SELECT COUNT(*) FROM clients) + (SELECT COUNT(*) FROM job_orders) + (SELECT COUNT(*) FROM placements) + (SELECT COUNT(*) FROM timesheets) + (SELECT COUNT(*) FROM call_log) + (SELECT COUNT(*) FROM email_log) as total").await.ok().and_then(|r| r.rows.as_array()?.first()?.get("total")?.as_i64()).unwrap_or(0);
let cache: Option<serde_json::Value> = match client.get(&format!("{}/query/cache/stats", api_base())).send().await {
Ok(r) => r.json().await.ok(),
Err(_) => None,
};
let indexes: Vec<serde_json::Value> = match client.get(&format!("{}/vectors/indexes", api_base())).send().await {
Ok(r) => r.json().await.unwrap_or_default(),
Err(_) => vec![],
};
let tools: Vec<serde_json::Value> = match client.get(&format!("{}/tools", api_base())).send().await {
Ok(r) => r.json().await.unwrap_or_default(),
Err(_) => vec![],
};
let jobs: Vec<serde_json::Value> = match client.get(&format!("{}/vectors/jobs", api_base())).send().await {
Ok(r) => r.json().await.unwrap_or_default(),
Err(_) => vec![],
};
let hnsw: Vec<String> = match client.get(&format!("{}/vectors/hnsw/list", api_base())).send().await {
Ok(r) => r.json().await.unwrap_or_default(),
Err(_) => vec![],
};
let journal: Option<serde_json::Value> = match client.get(&format!("{}/journal/stats", api_base())).send().await {
Ok(r) => r.json().await.ok(),
Err(_) => None,
};
stats.set(Some(serde_json::json!({
"datasets": datasets,
"total_rows": row_count,
"cache": cache,
"vector_indexes": indexes.len(),
"vector_total_chunks": indexes.iter().filter_map(|i| i.get("chunk_count")?.as_i64()).sum::<i64>(),
"hnsw_loaded": hnsw.len(),
"tools": tools.len(),
"jobs_total": jobs.len(),
"jobs_running": jobs.iter().filter(|j| j.get("status").and_then(|s| s.as_str()) == Some("running")).count(),
"journal": journal,
})));
loading.set(false);
});
};
// Auto-load on mount
// Auto-load on mount
use_effect(move || { do_load(); });
rsx! {
div { class: "panel",
div { class: "dashboard-hero",
h2 { "Lakehouse" }
p { class: "subtitle", "Rust-first data platform — SQL + AI + RAG over object storage" }
}
if let Some(s) = stats.read().as_ref() {
div { class: "stat-grid",
div { class: "stat-card",
div { class: "stat-value", "{s[\"datasets\"]}" }
div { class: "stat-label", "Datasets" }
}
div { class: "stat-card",
div { class: "stat-value", {format!("{:.1}M", s["total_rows"].as_i64().unwrap_or(0) as f64 / 1_000_000.0)} }
div { class: "stat-label", "Total Rows" }
}
div { class: "stat-card",
div { class: "stat-value", {format!("{}K", s["vector_total_chunks"].as_i64().unwrap_or(0) / 1000)} }
div { class: "stat-label", "Embeddings" }
}
div { class: "stat-card accent",
div { class: "stat-value", "{s[\"hnsw_loaded\"]}" }
div { class: "stat-label", "HNSW Indexes (27ms)" }
}
div { class: "stat-card",
div { class: "stat-value", "{s[\"tools\"]}" }
div { class: "stat-label", "Governed Tools" }
}
div { class: "stat-card",
div { class: "stat-value", {
s.get("cache")
.and_then(|c| c.get("datasets"))
.and_then(|d| d.as_i64())
.map(|n| format!("{n}"))
.unwrap_or("0".into())
} }
div { class: "stat-label", "Cached Datasets" }
}
}
div { class: "architecture-section",
h3 { "Architecture" }
div { class: "arch-grid",
div { class: "arch-card",
div { class: "arch-title", "Ingest" }
div { class: "arch-items", "CSV, JSON, PDF, Text, PostgreSQL, File Watcher" }
}
div { class: "arch-card",
div { class: "arch-title", "Storage" }
div { class: "arch-items", "Parquet on Object Storage, Delta Writes, Compaction" }
}
div { class: "arch-card",
div { class: "arch-title", "Query" }
div { class: "arch-items", "DataFusion SQL, MemCache (9.8x), Hot/Cold" }
}
div { class: "arch-card",
div { class: "arch-title", "AI" }
div { class: "arch-items", "Ollama (local), Embed, Generate, RAG, HNSW" }
}
div { class: "arch-card",
div { class: "arch-title", "Governance" }
div { class: "arch-items", "Event Journal, PII Detection, Tool Registry, Access Control" }
}
div { class: "arch-card",
div { class: "arch-title", "Agents" }
div { class: "arch-items", "Workspaces, Handoff, Shortlists, Activity Logs" }
}
}
}
div { class: "phases-section",
h3 { "Build Progression" }
div { class: "phase-list",
{rsx! {
PhaseItem { num: "0-5", name: "Foundation", detail: "Storage, Catalog, DataFusion, AI, UI, gRPC" }
PhaseItem { num: "6", name: "Ingest Pipeline", detail: "CSV/JSON/PDF/Text auto-schema" }
PhaseItem { num: "7", name: "Vector + RAG", detail: "Embed, Search, LLM Answers" }
PhaseItem { num: "8", name: "Hot Cache", detail: "9.8x speedup, Delta Writes" }
PhaseItem { num: "8.5", name: "Agent Workspaces", detail: "Per-contract, Instant Handoff" }
PhaseItem { num: "9", name: "Event Journal", detail: "Append-only Mutation History" }
PhaseItem { num: "10", name: "Rich Catalog", detail: "PII Detection, Lineage" }
PhaseItem { num: "11", name: "Embedding Versioning", detail: "Model-proof Vectors" }
PhaseItem { num: "12", name: "Tool Registry", detail: "6 Governed Actions + Audit" }
PhaseItem { num: "13", name: "Access Control", detail: "Role-based, Field-level" }
PhaseItem { num: "14", name: "Schema Evolution", detail: "Diff Detection, AI Migration" }
PhaseItem { num: "15", name: "HNSW Index", detail: "100K Search in 27ms" }
PhaseItem { num: "16", name: "File Watcher", detail: "Auto-ingest from Inbox" }
PhaseItem { num: "17", name: "DB Connector", detail: "PostgreSQL Import" }
}}
}
}
} else if *loading.read() {
div { class: "loading", "loading dashboard..." }
}
button { class: "btn", onclick: move |_| do_load(), "Refresh" }
}
}
}
#[component]
fn PhaseItem(num: String, name: String, detail: String) -> Element {
rsx! {
div { class: "phase-item",
span { class: "phase-num", "{num}" }
span { class: "phase-name", "{name}" }
span { class: "phase-detail", "{detail}" }
}
}
}
// === INGEST — Data on-ramp ===
#[component]
fn IngestPanel() -> Element {
let mut pg_host = use_signal(|| "127.0.0.1".to_string());
let mut pg_db = use_signal(|| "knowledge_base".to_string());
let mut pg_tables = use_signal(|| None::<Vec<String>>);
let mut pg_result = use_signal(|| None::<Result<serde_json::Value, String>>);
let mut pg_loading = use_signal(|| false);
let list_tables = move |_| {
let host = pg_host.read().clone();
let db = pg_db.read().clone();
spawn(async move {
pg_loading.set(true);
let client = reqwest::Client::new();
let resp = client.post(&format!("{}/ingest/postgres/tables", api_base()))
.json(&serde_json::json!({"host": host, "port": 5432, "database": db, "user": "postgres", "password": ""}))
.send().await;
match resp {
Ok(r) => {
if let Ok(tables) = r.json::<Vec<String>>().await {
pg_tables.set(Some(tables));
}
}
Err(e) => pg_tables.set(None),
}
pg_loading.set(false);
});
};
let mut import_table = move |table: String| {
let host = pg_host.read().clone();
let db = pg_db.read().clone();
spawn(async move {
pg_result.set(None);
let client = reqwest::Client::new();
let resp = client.post(&format!("{}/ingest/postgres/import", api_base()))
.json(&serde_json::json!({"host": host, "port": 5432, "database": db, "user": "postgres", "password": "", "table": table}))
.send().await;
match resp {
Ok(r) => {
match r.json::<serde_json::Value>().await {
Ok(v) => pg_result.set(Some(Ok(v))),
Err(e) => pg_result.set(Some(Err(e.to_string()))),
}
}
Err(e) => pg_result.set(Some(Err(e.to_string()))),
}
});
};
rsx! {
div { class: "panel",
h2 { "Data Ingest" }
p { class: "subtitle", "Bring data in from files, databases, or the auto-watch inbox" }
div { class: "panel-section",
h3 { "File Upload" }
p { class: "hint", "POST a file to /ingest/file — or drop it in ./inbox/ for auto-ingest" }
div { class: "arch-card",
div { class: "arch-title", "Supported Formats" }
div { class: "arch-items", "CSV (auto-schema) | JSON (nested flattening) | PDF (text extraction) | Text/SMS" }
}
div { class: "arch-card",
div { class: "arch-title", "Auto-Watch Inbox" }
div { class: "arch-items", "Drop files in ./inbox/ → auto-detected → Parquet → queryable in <15s" }
}
}
div { class: "panel-section",
h3 { "PostgreSQL Import" }
div { class: "form-row",
label { "Host" }
input { value: "{pg_host}", oninput: move |e| pg_host.set(e.value()) }
}
div { class: "form-row",
label { "Database" }
input { value: "{pg_db}", oninput: move |e| pg_db.set(e.value()) }
}
button { class: "btn", disabled: *pg_loading.read(), onclick: list_tables, "List Tables" }
if let Some(tables) = pg_tables.read().as_ref() {
div { class: "table-list",
for table in tables.iter() {
{
let t = table.clone();
let t2 = table.clone();
rsx! {
div { class: "table-item",
span { "{t}" }
button { class: "btn btn-sm", onclick: move |_| import_table(t2.clone()), "Import" }
}
}
}
}
}
}
if let Some(result) = pg_result.read().as_ref() {
match result {
Ok(v) => rsx! {
div { class: "result-box",
pre { {serde_json::to_string_pretty(v).unwrap_or_default()} }
}
},
Err(e) => rsx! { div { class: "error", "{e}" } },
}
}
}
}
}
}
// === STATUS — System health ===
#[component]