diff --git a/crates/ui/assets/style.css b/crates/ui/assets/style.css index 8e56834..1764800 100644 --- a/crates/ui/assets/style.css +++ b/crates/ui/assets/style.css @@ -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); } diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs index 4371d4e..38def9e 100644 --- a/crates/ui/src/main.rs +++ b/crates/ui/src/main.rs @@ -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::::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::); + 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 = match client.get(&format!("{}/query/cache/stats", api_base())).send().await { + Ok(r) => r.json().await.ok(), + Err(_) => None, + }; + + let indexes: Vec = match client.get(&format!("{}/vectors/indexes", api_base())).send().await { + Ok(r) => r.json().await.unwrap_or_default(), + Err(_) => vec![], + }; + + let tools: Vec = match client.get(&format!("{}/tools", api_base())).send().await { + Ok(r) => r.json().await.unwrap_or_default(), + Err(_) => vec![], + }; + + let jobs: Vec = match client.get(&format!("{}/vectors/jobs", api_base())).send().await { + Ok(r) => r.json().await.unwrap_or_default(), + Err(_) => vec![], + }; + + let hnsw: Vec = match client.get(&format!("{}/vectors/hnsw/list", api_base())).send().await { + Ok(r) => r.json().await.unwrap_or_default(), + Err(_) => vec![], + }; + + let journal: Option = 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::(), + "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::>); + let mut pg_result = use_signal(|| None::>); + 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::>().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::().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]