diff --git a/.gitignore b/.gitignore index 81e97ab..854f386 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ docs/nodedb_lab_studio_mockup_v4.html specs/* docs/* .DS_Store + +# Subagent-driven-development scratch (ledger, briefs, review packages) +.superpowers/ diff --git a/nodedb-studio/assets/styles.css b/nodedb-studio/assets/styles.css index 9796ead..567d392 100644 --- a/nodedb-studio/assets/styles.css +++ b/nodedb-studio/assets/styles.css @@ -394,6 +394,19 @@ /* ============================================================ Modal overlay ============================================================ */ + /* Connect failures overlay everything, the modal scrim (z-index 150) + included: a failed "Save & connect" must be readable without closing the + form that caused it. Fixed, because html/body are 100vh with overflow + hidden, so a banner in normal flow pushes the shell off-screen. */ + .connect-error-bar { + position: fixed; + top: 0; left: 0; right: 0; + z-index: 200; + background: var(--bg-secondary); + border-bottom: 0.5px solid var(--border-mid); + } + .connect-error-bar .async-error { padding: 12px 20px; } + .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); @@ -441,6 +454,11 @@ font-size: 13px; } .form-field input:focus, .form-field select:focus { border-color: var(--accent); } + .field-error { + margin-top: 5px; + font-size: 11px; + color: var(--text-danger); + } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } /* ============================================================ diff --git a/nodedb-studio/src/app.rs b/nodedb-studio/src/app.rs index e0c01fd..76a1e90 100644 --- a/nodedb-studio/src/app.rs +++ b/nodedb-studio/src/app.rs @@ -1,6 +1,7 @@ //! Root component: provides global state and the top-level state machine. //! -//! Two states, handled by a root conditional (NOT routing — see CLAUDE.md §5): +//! Two states, handled by a root conditional rather than by routing, because +//! the Connection Manager is not a route: the Router mounts inside `Studio`. //! - Disconnected -> `ConnectionManager` (full screen, no studio chrome) //! - Connected -> `Studio` //! @@ -12,12 +13,13 @@ use std::rc::Rc; use dioxus::prelude::*; +use crate::components::async_view::AsyncView; use crate::modals::ModalHost; use crate::models::notification::Notification; use crate::services::async_state::AsyncState; use crate::services::backend::Backend; use crate::services::connection_service::MockConnectionService; -use crate::state::connection::ActiveConnection; +use crate::state::connection::{ActiveConnection, ConnectError}; use crate::state::connections_registry::SavedConnection; use crate::state::preferences::Preferences; use crate::state::ui::ModalKind; @@ -47,6 +49,10 @@ pub fn App() -> Element { // Modal state is provided here (not in Studio) because Preferences is // reachable while disconnected and via Cmd+, in either state. use_context_provider(|| Signal::new(None::)); + // Connect failures are provided here, not inside the views that start them: + // the command palette and the switch popover both close on click, so an + // error they owned would be dropped before it could render. + let connect_error = use_context_provider(|| Signal::new(ConnectError(None))); // Seed the registry + notification feed asynchronously, at the seam. The // mock resolves instantly; the real client awaits the network. The guard @@ -74,9 +80,25 @@ pub fn App() -> Element { use_context_provider(|| reload_feed); let active = use_context::>>(); + // Rendered through the same component every failed read uses, so the markup + // and styling live in one place. It is fixed-position and above the modal + // scrim: `html, body` are `100vh; overflow: hidden`, so a banner in normal + // flow would push the statusbar off-screen, and a failed "Save & connect" + // has to be readable without closing the form that caused it. + // `retriable` is deliberately false: the way + // to retry a connect is the Connect button the user just pressed, which is + // still on screen, and a second affordance here would need the name and + // credentials of the attempt that failed. The message clears when the next + // attempt starts (see the call sites) or succeeds. + let connect_error_msg = connect_error.read().0.as_ref().map(|e| e.to_string()); rsx! { document::Stylesheet { href: STYLES } + if let Some(msg) = connect_error_msg { + div { class: "connect-error-bar", + AsyncView { loading: false, empty: false, error: Some(msg) } + } + } if active.read().is_some() { Studio {} } else { diff --git a/nodedb-studio/src/components/async_view.rs b/nodedb-studio/src/components/async_view.rs index d5c5842..3bbd48b 100644 --- a/nodedb-studio/src/components/async_view.rs +++ b/nodedb-studio/src/components/async_view.rs @@ -1,4 +1,5 @@ -//! Shared loading/empty/error renderer for any seam-backed read. +//! Shared loading/empty/error renderer for any seam-backed read, and the +//! error/retry renderer for a seam-backed write. //! //! The caller maps its `use_resource` result to `AsyncState` (plain Rust), //! renders the `Loaded(T)` case itself, and delegates the three non-loaded diff --git a/nodedb-studio/src/components/command_palette.rs b/nodedb-studio/src/components/command_palette.rs index ae493ac..8f45340 100644 --- a/nodedb-studio/src/components/command_palette.rs +++ b/nodedb-studio/src/components/command_palette.rs @@ -3,19 +3,25 @@ //! Rendered inside the router (via `StudioLayout`) so navigation items can use //! the navigator. Open state is the shared `Signal` provided by `Studio`. +use dioxus::core::spawn_forever; use dioxus::prelude::*; use crate::routes::Route; use crate::services::backend::Backend; -use crate::state::connection::ActiveConnection; +use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; +use crate::state::connections_registry::{Credentials, SavedConnection}; use crate::state::ui::ModalKind; #[component] pub fn CommandPalette() -> Element { let mut open = use_context::>(); let mut active = use_context::>>(); + // The palette closes on click, so a failure it owned would never render; + // the surface lives at the app root instead. + let mut connect_error = use_context::>(); let mut modal = use_context::>>(); let service = use_context::>(); + let registry = use_context::>>(); let nav = use_navigator(); if !*open.read() { @@ -26,6 +32,30 @@ pub fn CommandPalette() -> Element { // each switch handler clones it. let switch_svc = service.clone(); + // A saved entry's stored profile IS its explicit username; the palette + // switches between entries that already have one, so there is no field to + // type into here. An entry with no profile yields a blank, which the seam + // rejects with MissingUsername and the app root renders — never a silent + // fallback to `admin`. The connections fixture carries the invariant + // that keeps connectable entries from reaching that state. + let creds_for = |name: &str| -> Credentials { + Credentials { + username: registry + // .read(), not .peek(): this runs at render time, not in an + // event handler. peek() would freeze the credentials at the + // render where the palette opened, so a registry that resolves + // later (a real backend awaiting the network) would leave every + // switch sending a blank username with no re-render to fix it. + .read() + .iter() + .find(|c| c.name == name) + .and_then(|c| c.profile.as_ref()) + .map(|p| p.user.clone()) + .unwrap_or_default(), + password: None, + } + }; + rsx! { div { class: "palette-overlay open", @@ -66,10 +96,17 @@ pub fn CommandPalette() -> Element { div { class: "palette-section", "Connections" } div { class: "palette-item", onclick: { let svc = switch_svc.clone(); + let creds = creds_for("staging-cluster"); move |_| { let svc = svc.clone(); - spawn(async move { - if let Ok(s) = svc.connect("staging-cluster").await { active.set(Some(s)); } + let creds = creds.clone(); + connect_error.set(ConnectError(None)); + // spawn_forever: the palette closes on the next + // line, and a scope-bound task would be dropped. + spawn_forever(async move { + let result = svc.connect("staging-cluster", &creds).await; + let err = apply_connect(&mut active.write(), result); + connect_error.set(ConnectError(err)); }); open.set(false); } @@ -78,10 +115,17 @@ pub fn CommandPalette() -> Element { } div { class: "palette-item", onclick: { let svc = switch_svc.clone(); + let creds = creds_for("prod-replica-eu"); move |_| { let svc = svc.clone(); - spawn(async move { - if let Ok(s) = svc.connect("prod-replica-eu").await { active.set(Some(s)); } + let creds = creds.clone(); + connect_error.set(ConnectError(None)); + // spawn_forever: the palette closes on the next + // line, and a scope-bound task would be dropped. + spawn_forever(async move { + let result = svc.connect("prod-replica-eu", &creds).await; + let err = apply_connect(&mut active.write(), result); + connect_error.set(ConnectError(err)); }); open.set(false); } diff --git a/nodedb-studio/src/components/popovers/connection_popover.rs b/nodedb-studio/src/components/popovers/connection_popover.rs index 3af40d1..b6eeb3a 100644 --- a/nodedb-studio/src/components/popovers/connection_popover.rs +++ b/nodedb-studio/src/components/popovers/connection_popover.rs @@ -3,11 +3,12 @@ use std::rc::Rc; +use dioxus::core::spawn_forever; use dioxus::prelude::*; use crate::services::backend::Backend; -use crate::state::connection::ActiveConnection; -use crate::state::connections_registry::{ConnStatus, SavedConnection}; +use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; +use crate::state::connections_registry::{ConnStatus, Credentials, SavedConnection}; use crate::state::ui::{ModalKind, Popover}; #[component] @@ -17,6 +18,9 @@ pub fn ConnectionPopover() -> Element { let mut modal = use_context::>>(); let registry = use_context::>>(); let service = use_context::>(); + // The popover closes on click (see `popover.set(None)` below), so a failure + // it owned would never render; the surface lives at the app root instead. + let mut connect_error = use_context::>(); let conn = active.read(); let Some(c) = conn.as_ref() else { @@ -50,6 +54,18 @@ pub fn ConnectionPopover() -> Element { }; let svc = service.clone(); let item_class = if disabled { "cp-item disabled" } else { "cp-item" }; + // The stored profile IS this entry's explicit username; the + // popover only switches between already-saved connections. A + // profile-less entry yields a blank, which the seam rejects + // with MissingUsername and the app root renders. + let creds = Credentials { + username: sc + .profile + .as_ref() + .map(|p| p.user.clone()) + .unwrap_or_default(), + password: None, + }; rsx! { div { class: "{item_class}", @@ -59,8 +75,18 @@ pub fn ConnectionPopover() -> Element { // set `active` (Copy) only after the await resolves. let svc = svc.clone(); let name = name.clone(); - spawn(async move { - if let Ok(s) = svc.connect(&name).await { active.set(Some(s)); } + let creds = creds.clone(); + connect_error.set(ConnectError(None)); + // spawn_forever, not spawn: this popover is + // conditionally mounted and closes on the next + // line, and Dioxus drops a scope's tasks when + // the scope goes away. A plain spawn dies at + // the await against any backend that actually + // yields, leaving no session and no error. + spawn_forever(async move { + let result = svc.connect(&name, &creds).await; + let err = apply_connect(&mut active.write(), result); + connect_error.set(ConnectError(err)); }); popover.set(None); } diff --git a/nodedb-studio/src/components/popovers/notification_popover.rs b/nodedb-studio/src/components/popovers/notification_popover.rs index 19a64cc..e3b5c5f 100644 --- a/nodedb-studio/src/components/popovers/notification_popover.rs +++ b/nodedb-studio/src/components/popovers/notification_popover.rs @@ -9,9 +9,14 @@ //! list itself. Mark-all-read / per-item clicks MUTATE the store, so the badge //! and the list never diverge. The Error-state Retry reloads the feed via the //! shared `Resource` handle, gated on `StudioError::is_retriable()`. +//! +//! A failed mark-all-read is a WRITE failure and is kept out of the read store: +//! the list the user was looking at is still correct, so it stays on screen and +//! the failure renders beside it, with a retry that re-issues the write. use std::rc::Rc; +use dioxus::core::spawn_forever; use dioxus::prelude::*; use crate::components::async_view::AsyncView; @@ -19,8 +24,9 @@ use crate::models::notification::{Notification, NotificationTarget}; use crate::routes::Route; use crate::services::async_state::AsyncState; use crate::services::backend::Backend; +use crate::services::error::StudioError; use crate::state::connection::{ActiveConnection, Capabilities}; -use crate::state::notifications::{mark_all_read, mark_read, visible}; +use crate::state::notifications::{apply_mark_all_read, mark_read, visible}; use crate::state::ui::Popover; /// Where a notification navigates when clicked. @@ -50,6 +56,32 @@ pub fn NotificationPopover() -> Element { let active = use_context::>>(); let backend = use_context::>(); let nav = use_navigator(); + // Write failure for mark-all-read, separate from the read store. + let mut write_error: Signal> = use_signal(|| None); + // Persist first; the local list changes only once the seam acknowledges + // the write (see apply_mark_all_read). The spawn keeps every signal guard + // out of the await. Built once so the header button and its Retry are the + // same operation, not two copies that can drift. + // + // spawn_forever, not spawn: this popover is conditionally mounted, and + // Dioxus drops a scope's tasks on unmount. Clicking away while the write + // is in flight would kill it at the await, leaving the server's state + // unknown and the shared store never reconciled. The store is app-level + // context, so the reconcile still lands; only `write_error` is scoped + // here, so a failure the user navigated away from is not shown. That is + // acceptable because the failure path leaves the list untouched, which is + // what reopening the popover shows. + let mark_all = { + let backend = backend.clone(); + move || { + let backend = backend.clone(); + spawn_forever(async move { + let result = backend.mark_all_read().await; + let err = apply_mark_all_read(&mut store.write(), result); + write_error.set(err); + }); + } + }; // Capability gate (unchanged): no connection -> render nothing. let caps: Capabilities = match active.read().as_ref() { @@ -89,29 +121,29 @@ pub fn NotificationPopover() -> Element { div { class: "notif-header", h4 { "Notifications " span { class: "count", "{count_label}" } } button { - onclick: move |_| { - // In-memory update first for snappy UI (write guard dropped - // before the block ends, never held across an await). - if let Some(items) = store.write().loaded_mut() { - mark_all_read(items); - } - // Persist through the seam so the badge stays cleared on - // any subsequent reload (fixes POP-03). The spawn avoids - // holding any signal guard across the await. - // On success, reconcile the shared feed so the real client's - // persisted state is reflected (reload.restart() re-fetches). - let backend = backend.clone(); - let mut reload = reload; - spawn(async move { - match backend.mark_all_read().await { - Ok(()) => reload.restart(), - Err(e) => tracing::warn!("mark_all_read failed: {e}"), - } - }); + onclick: { + let mark_all = mark_all.clone(); + move |_| mark_all() }, "Mark all read" } } + // A failed write renders through the same component as a failed + // read, so it is styled, tested, and gated on retriability exactly + // once. Its Retry re-issues the write, not the read. + if let Some(e) = write_error.read().as_ref() { + AsyncView { + loading: false, + empty: false, + error: Some(format!("Could not mark all read: {e}")), + retriable: e.is_retriable(), + empty_message: String::new(), + on_retry: { + let mark_all = mark_all.clone(); + move |_| mark_all() + }, + } + } div { class: "notif-list", // Loading / Empty / Error -> shared AsyncView, driven by AsyncState. AsyncView { diff --git a/nodedb-studio/src/data/mock/admin.rs b/nodedb-studio/src/data/mock/admin.rs new file mode 100644 index 0000000..13c6f51 --- /dev/null +++ b/nodedb-studio/src/data/mock/admin.rs @@ -0,0 +1,143 @@ +//! Admin fixtures. Shapes mirror the server's introspection output so the +//! real implementation is a decoder swap, not a model change. + +use crate::models::admin::{AuditEntry, ClusterNode, RaftGroup, RlsPolicy, ShardRange, UserRow}; + +/// Cluster topology: one row per node. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn cluster_nodes() -> Vec { + vec![ + ClusterNode { + id: "1".into(), + address: "127.0.0.1:6433".into(), + state: "active".into(), + raft_groups: "6".into(), + }, + ClusterNode { + id: "2".into(), + address: "127.0.0.2:6433".into(), + state: "active".into(), + raft_groups: "5".into(), + }, + ClusterNode { + id: "3".into(), + address: "127.0.0.3:6433".into(), + state: "degraded".into(), + raft_groups: "4".into(), + }, + ] +} + +/// Raft groups for the cluster. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn raft_groups() -> Vec { + (0..4) + .map(|i| RaftGroup { + id: i.to_string(), + role: if i == 0 { "Leader" } else { "Follower" }.into(), + leader_id: "1".into(), + term: "1".into(), + commit_index: (100 + i).to_string(), + last_applied: (100 + i).to_string(), + members: "1,2,3".into(), + }) + .collect() +} + +/// Shard ranges and their leaseholders. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn shard_ranges() -> Vec { + (0..8) + .map(|i| ShardRange { + id: i.to_string(), + group_id: ((i % 3) + 1).to_string(), + leaseholder: ((i % 3) + 1).to_string(), + replicas: "1,2,3".into(), + qps: format!("{}.0", i * 12), + p99_ms: format!("{}.5", i + 1), + }) + .collect() +} + +/// RBAC: all users in the tenant. `id` deliberately differs from `username` +/// (a `u-N` handle vs. the login name), same reasoning as +/// `streams::materialized_views`: a list keyed by the wrong field must be +/// visible instead of invisible. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn users() -> Vec { + vec![ + UserRow { + id: "u-1".into(), + username: "admin".into(), + tenant_id: "1".into(), + roles: "superuser".into(), + is_superuser: true, + }, + UserRow { + id: "u-2".into(), + username: "alice".into(), + tenant_id: "1".into(), + roles: "reader".into(), + is_superuser: false, + }, + ] +} + +/// Row-level-security policies. `id` deliberately differs from `name`, same +/// reasoning as `users` above. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn rls_policies() -> Vec { + vec![ + RlsPolicy { + id: "rls-1".into(), + name: "tenant_isolation".into(), + collection: "orders".into(), + kind: "select".into(), + mode: "permissive".into(), + enabled: true, + }, + RlsPolicy { + id: "rls-2".into(), + name: "pii_masking".into(), + collection: "users".into(), + kind: "select".into(), + mode: "restrictive".into(), + enabled: false, + }, + ] +} + +/// Audit log entries. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn audit_entries() -> Vec { + (0..5) + .map(|i| AuditEntry { + id: format!("audit-{i}"), + when: format!("2026-08-08 10:0{i}:00"), + actor: "admin".into(), + action: "SELECT".into(), + target: "orders".into(), + result: "allowed".into(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::mock::test_support::{assert_ids_distinct_from_names, assert_unique_ids}; + + #[test] + fn users_have_unique_ids_distinct_from_username() { + let rows = users(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.username.as_str()))); + } + + #[test] + fn rls_policies_have_unique_ids_distinct_from_name() { + let rows = rls_policies(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str()))); + } +} diff --git a/nodedb-studio/src/data/mock/connections.rs b/nodedb-studio/src/data/mock/connections.rs index 9311e34..6e8b4cf 100644 --- a/nodedb-studio/src/data/mock/connections.rs +++ b/nodedb-studio/src/data/mock/connections.rs @@ -1,5 +1,5 @@ -use crate::models::collection::{Collection, StorageMode}; use crate::models::notification::{Notification, NotificationTarget, Severity}; +use crate::models::shell::{NavBadges, SessionInfo}; use crate::state::connection::{Capabilities, Capability}; use crate::state::connections_registry::{ConnStatus, ConnectionProfile, SavedConnection}; @@ -124,32 +124,6 @@ pub fn connections() -> Vec { ] } -/// Explorer collections, in sidebar display order (grouped by storage mode). -/// One NodeDB instance exposes all eight modes; these are not separate engines. -pub fn explorer_collections() -> Vec { - let c = |name: &str, mode, count: &str| Collection { - name: name.to_string(), - mode, - count: count.to_string(), - }; - vec![ - c("users", StorageMode::Document, "12,481"), - c("events", StorageMode::Document, "2.4M"), - c("sessions", StorageMode::Document, "88,209"), - c("orders", StorageMode::Strict, "442,003"), - c("invoices", StorageMode::Strict, "95,818"), - c("doc_embeddings", StorageMode::Vector, "1.1M"), - c("product_embeds", StorageMode::Vector, "88,400"), - c("social_graph", StorageMode::Graph, "3.2M"), - c("metrics", StorageMode::Timeseries, "48M"), - c("sensor_temps", StorageMode::Timeseries, "5.1M"), - c("sessions_cache", StorageMode::Kv, "18,200"), - c("feature_flags", StorageMode::Kv, "42"), - c("store_locations", StorageMode::Spatial, "2,108"), - c("articles_idx", StorageMode::Fts, "241,005"), - ] -} - /// The notification feed. Capability gating is applied at render time against /// the active connection (see `state::notifications`). pub fn notifications() -> Vec { @@ -222,3 +196,71 @@ pub fn notifications() -> Vec { }, ] } + +/// Nav-rail badge counts: pending items on the Query and Streams entries. +/// These must agree with the hardcoded literals in `components::rail` until +/// that later phase swaps the rail onto this seam method, so the eventual +/// wiring is a visual no-op. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn nav_badges() -> NavBadges { + NavBadges { + query: 3, + streams: 6, + } +} + +/// The active session summary shown in the statusbar. `server_version` is a +/// neutral "dev" placeholder: NodeDB version numbers are undecided, so no +/// specific version is invented here (see the module note in `mod.rs`). +#[allow(dead_code)] // SEAM-UNWIRED +pub fn session_info() -> SessionInfo { + SessionInfo { + database: "analytics".into(), + role: "admin".into(), + server_version: "dev".into(), + timezone: "UTC".into(), + read_only: false, + } +} + +/// Databases visible on the active connection, matching `local-nodedb-dev`'s +/// profile above. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn databases() -> Vec { + vec![ + "analytics".into(), + "events_log".into(), + "social_graph".into(), + "iot_telemetry".into(), + "docs_corpus".into(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `connect()` builds its username from `profile.map(|p| p.user) + /// .unwrap_or_default()`, so a connectable entry with no profile would + /// silently produce a blank username, get rejected by the seam's + /// `MissingUsername` guard, and have that error dropped by every call + /// site's `if let Ok(..)` — a Connect button that does nothing, with no + /// error and no state change. Nothing in the types prevents that + /// combination; this test makes the fixture invariant that avoids it + /// break loudly instead. + #[test] + fn every_connectable_entry_has_a_profile() { + let conns = connections(); + assert!(!conns.is_empty(), "fixture must not be empty"); + for c in &conns { + if c.status.is_connectable() { + assert!( + c.profile.is_some(), + "{} is connectable but has no profile: connect() would \ + default its username to blank and silently no-op", + c.name + ); + } + } + } +} diff --git a/nodedb-studio/src/data/mock/explorer.rs b/nodedb-studio/src/data/mock/explorer.rs new file mode 100644 index 0000000..dd586ef --- /dev/null +++ b/nodedb-studio/src/data/mock/explorer.rs @@ -0,0 +1,79 @@ +//! Explorer fixtures: grouped collections, list rows, and detail bodies. + +use crate::models::collection::{Collection, StorageMode}; +use crate::models::explorer::{CollectionGroup, RecordDetail, RecordRow}; + +fn collection(name: &str, mode: StorageMode, count: &str) -> Collection { + Collection { + name: name.to_string(), + mode, + count: count.to_string(), + } +} + +/// Grouped in the canonical `StorageMode` display order. +pub fn collection_groups() -> Vec { + vec![ + CollectionGroup { + mode: StorageMode::Document, + collections: vec![ + collection("users", StorageMode::Document, "12,481"), + collection("orders", StorageMode::Document, "98,204"), + ], + }, + CollectionGroup { + mode: StorageMode::Strict, + collections: vec![collection("accounts", StorageMode::Strict, "3,921")], + }, + CollectionGroup { + mode: StorageMode::Vector, + collections: vec![collection("embeddings", StorageMode::Vector, "2.4M")], + }, + CollectionGroup { + mode: StorageMode::Graph, + collections: vec![collection("social", StorageMode::Graph, "44,010")], + }, + CollectionGroup { + mode: StorageMode::Timeseries, + collections: vec![collection("metrics", StorageMode::Timeseries, "8.1M")], + }, + CollectionGroup { + mode: StorageMode::Kv, + collections: vec![collection("sessions", StorageMode::Kv, "51,003")], + }, + CollectionGroup { + mode: StorageMode::Spatial, + collections: vec![collection("places", StorageMode::Spatial, "1,204")], + }, + CollectionGroup { + mode: StorageMode::Fts, + collections: vec![collection("articles", StorageMode::Fts, "22,847")], + }, + ] +} + +/// List rows for a collection. Deterministic and keyed by `id`. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn records(collection: &str) -> Vec { + (0..6) + .map(|i| RecordRow { + id: format!("{collection}-{i}"), + cells: vec![ + format!("{collection}-{i}"), + format!("row {i}"), + format!("2026-08-0{} 10:0{}:00", (i % 9) + 1, i), + ], + }) + .collect() +} + +/// Detail body for one record. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn record_detail(collection: &str, id: &str) -> RecordDetail { + RecordDetail { + id: id.to_string(), + title: format!("{collection} / {id}"), + body_json: format!("{{\"id\":\"{id}\",\"collection\":\"{collection}\"}}"), + footer: "mock fixture".to_string(), + } +} diff --git a/nodedb-studio/src/data/mock/mod.rs b/nodedb-studio/src/data/mock/mod.rs index f3e8ed8..e9b6e76 100644 --- a/nodedb-studio/src/data/mock/mod.rs +++ b/nodedb-studio/src/data/mock/mod.rs @@ -3,14 +3,28 @@ //! //! Naming note: the mockup's legacy "arcadedb" labels, "local-arcade-dev" //! name, "arcade-5" node, and per-version server tags are deliberately NOT -//! reproduced. NodeDB version numbers are undecided (CLAUDE.md §2), so the +//! reproduced. NodeDB version numbers are not settled, so the //! server stat is a neutral "dev" placeholder rather than an invented version. +mod admin; mod cdc; mod connections; mod docs; -mod notify; +mod explorer; +mod streams; +#[cfg(test)] +mod test_support; +mod viewers; +mod workbench; +pub use admin::{audit_entries, cluster_nodes, raft_groups, rls_policies, shard_ranges, users}; pub use cdc::{ChangeOp, cdc_events}; -pub use connections::{connections, explorer_collections, notifications}; -pub use notify::{notify_channels, notify_messages}; +pub use connections::{connections, databases, nav_badges, notifications, session_info}; +pub use explorer::{collection_groups, record_detail, records}; +pub use streams::{ + materialized_views, notify_channel_rows, notify_message_rows, scheduled_jobs, topics, +}; +pub use viewers::{ + empty_sub_graph, fts_hits, series, spatial_features, sub_graph, sync_peers, vector_points, +}; +pub use workbench::{empty_result_set, query_plan, result_set, schema_tree}; diff --git a/nodedb-studio/src/data/mock/notify.rs b/nodedb-studio/src/data/mock/notify.rs deleted file mode 100644 index 8dab253..0000000 --- a/nodedb-studio/src/data/mock/notify.rs +++ /dev/null @@ -1,75 +0,0 @@ -use super::docs::{FieldValue, MockDoc, doc}; -use FieldValue::Str; - -/// A LISTEN/NOTIFY channel in the sidebar. -pub struct NotifyChannel { - pub name: &'static str, - pub listeners: &'static str, - pub active: bool, -} - -/// One row in the LISTEN/NOTIFY live tail. -pub struct NotifyMessage { - pub time: &'static str, - pub source: &'static str, - pub payload: MockDoc, -} - -/// The notify channel list. -pub fn notify_channels() -> Vec { - let ch = |name, listeners, active| NotifyChannel { - name, - listeners, - active, - }; - vec![ - ch("user_events", "12", true), - ch("deploy_hooks", "3", false), - ch("cache_invalidate", "5", false), - ch("alerts", "8", false), - ch("jobs_done", "14", false), - ch("presence_room_1", "22", false), - ] -} - -/// The pub/sub message tail for the active channel. -pub fn notify_messages() -> Vec { - vec![ - NotifyMessage { - time: "04:23:18.041", - source: "api-server-2", - payload: doc(vec![("event", Str("login")), ("user", Str("u_44182"))]), - }, - NotifyMessage { - time: "04:23:17.812", - source: "webhook-relay", - payload: doc(vec![ - ("event", Str("signup")), - ("user", Str("u_99001")), - ("plan", Str("pro")), - ]), - }, - NotifyMessage { - time: "04:23:17.501", - source: "api-server-1", - payload: doc(vec![ - ("event", Str("profile_update")), - ("user", Str("u_77103")), - ]), - }, - NotifyMessage { - time: "04:23:16.998", - source: "analytics", - payload: doc(vec![ - ("event", Str("page_view")), - ("user", Str("u_44182")), - ("path", Str("/pricing")), - ]), - }, - NotifyMessage { - time: "04:23:16.422", - source: "api-server-2", - payload: doc(vec![("event", Str("logout")), ("user", Str("u_31001"))]), - }, - ] -} diff --git a/nodedb-studio/src/data/mock/streams.rs b/nodedb-studio/src/data/mock/streams.rs new file mode 100644 index 0000000..ae5c589 --- /dev/null +++ b/nodedb-studio/src/data/mock/streams.rs @@ -0,0 +1,178 @@ +//! Streams fixtures beyond CDC (`data::mock::cdc`): materialized views, +//! durable topics, scheduled jobs and LISTEN/NOTIFY. Shapes mirror the +//! server's introspection output so the real implementation is a decoder +//! swap, not a model change. +//! +//! `notify_channel_rows`/`notify_message_rows` are deliberately not named +//! `notify_channels`/`notify_messages`: those names are already taken at +//! `data::mock`'s root by the pre-seam fixtures `views::streams::notify` +//! reads directly (see that module's doc comment). The two fixture sets +//! describe different shapes and are wired independently; task 10 reconciles +//! the view onto the seam. + +use crate::models::streams::{MaterializedView, NotifyChannel, NotifyMessage, ScheduledJob, Topic}; + +/// Materialized views known to the cluster. `id` deliberately differs from +/// `name` (a `mv-N` handle vs. the human-readable view name) so a later bug +/// that keys a list by the wrong field is visible instead of invisible. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn materialized_views() -> Vec { + vec![ + MaterializedView { + id: "mv-1".into(), + name: "mv_top_users_24h".into(), + source: "events".into(), + refresh_mode: "incremental".into(), + rows: "12,481".into(), + }, + MaterializedView { + id: "mv-2".into(), + name: "mv_daily_revenue".into(), + source: "orders".into(), + refresh_mode: "scheduled".into(), + rows: "3,204".into(), + }, + ] +} + +/// Durable, replayable topics with their consumer lag. `id` deliberately +/// differs from `name`, same reasoning as `materialized_views`. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn topics() -> Vec { + vec![ + Topic { + id: "topic-1".into(), + name: "order_placed".into(), + partitions: "8".into(), + messages: "2.4M".into(), + retention: "7d".into(), + consumers: "3 active".into(), + lag: "142ms".into(), + }, + Topic { + id: "topic-2".into(), + name: "user_signup".into(), + partitions: "4".into(), + messages: "88,209".into(), + retention: "30d".into(), + consumers: "2 active".into(), + lag: "22ms".into(), + }, + Topic { + id: "topic-3".into(), + name: "payment_failed".into(), + partitions: "2".into(), + messages: "12,488".into(), + retention: "90d".into(), + consumers: "1 active · 1 stalled".into(), + lag: "4.2s".into(), + }, + ] +} + +/// Cron-style scheduled jobs. Deliberately mixes a failed job in with +/// successful ones so fixture-content tests can't be satisfied by an +/// accidentally-uniform status column. `id` deliberately differs from `name`, +/// same reasoning as `materialized_views`. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn scheduled_jobs() -> Vec { + vec![ + ScheduledJob { + id: "job-1".into(), + name: "nightly_rollup".into(), + cron: "0 2 * * *".into(), + last_status: "success".into(), + next_run: "in 2h 14m".into(), + }, + ScheduledJob { + id: "job-2".into(), + name: "session_cleanup".into(), + cron: "*/15 * * * *".into(), + last_status: "success".into(), + next_run: "in 11m".into(), + }, + ScheduledJob { + id: "job-3".into(), + name: "vector_reindex".into(), + cron: "0 4 * * 0".into(), + last_status: "failed · oom".into(), + next_run: "in 4d 8h".into(), + }, + ] +} + +/// LISTEN/NOTIFY channels. `id` deliberately differs from `name`, same +/// reasoning as `materialized_views`. +pub fn notify_channel_rows() -> Vec { + vec![ + NotifyChannel { + id: "channel-1".into(), + name: "user_events".into(), + subscribers: "12".into(), + }, + NotifyChannel { + id: "channel-2".into(), + name: "deploy_hooks".into(), + subscribers: "3".into(), + }, + NotifyChannel { + id: "channel-3".into(), + name: "cache_invalidate".into(), + subscribers: "5".into(), + }, + ] +} + +/// The pub/sub message tail across channels. +pub fn notify_message_rows() -> Vec { + (0..4) + .map(|i| NotifyMessage { + id: format!("notify-{i}"), + channel: "user_events".into(), + at: format!("04:23:{:02}.041", 18 - i), + payload_json: format!("{{\"event\":\"login\",\"seq\":{i}}}"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::mock::test_support::{assert_ids_distinct_from_names, assert_unique_ids}; + + #[test] + fn materialized_views_have_unique_ids_distinct_from_name() { + let rows = materialized_views(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str()))); + } + + #[test] + fn topics_have_unique_ids_distinct_from_name() { + let rows = topics(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str()))); + } + + #[test] + fn scheduled_jobs_have_unique_ids_distinct_from_name_and_a_mixed_status() { + let rows = scheduled_jobs(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str()))); + assert!(rows.iter().any(|j| j.last_status == "success")); + assert!(rows.iter().any(|j| j.last_status.starts_with("failed"))); + } + + #[test] + fn notify_channel_rows_have_unique_ids_distinct_from_name() { + let rows = notify_channel_rows(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str()))); + } + + #[test] + fn notify_message_rows_have_unique_ids() { + let rows = notify_message_rows(); + assert_unique_ids(&rows.iter().map(|r| r.id.as_str()).collect::>()); + } +} diff --git a/nodedb-studio/src/data/mock/test_support.rs b/nodedb-studio/src/data/mock/test_support.rs new file mode 100644 index 0000000..2eafa87 --- /dev/null +++ b/nodedb-studio/src/data/mock/test_support.rs @@ -0,0 +1,26 @@ +//! Shared fixture-invariant helpers used by more than one mock module's +//! tests. Test-only: this whole module is behind `#[cfg(test)]` at the +//! declaration site in `mod.rs`. + +/// Every id in the fixture must be unique — a list keyed by a duplicate id +/// would silently overwrite one row with another in the UI. +pub(crate) fn assert_unique_ids(ids: &[&str]) { + assert!(!ids.is_empty(), "fixture must not be empty"); + let mut sorted = ids.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), ids.len(), "ids must be unique"); +} + +/// A list keyed by the wrong field (e.g. `name` instead of `id`) still +/// passes `assert_unique_ids` when the fixture happens to have `id == name`, +/// so that mistake stays invisible. Fixtures should give every row a +/// distinct `id`/`name` pair to make it visible. +pub(crate) fn assert_ids_distinct_from_names<'a>(rows: impl Iterator) { + let mut saw_any = false; + for (id, name) in rows { + saw_any = true; + assert_ne!(id, name, "id must not equal name: {id}"); + } + assert!(saw_any, "fixture must not be empty"); +} diff --git a/nodedb-studio/src/data/mock/viewers.rs b/nodedb-studio/src/data/mock/viewers.rs new file mode 100644 index 0000000..52d7303 --- /dev/null +++ b/nodedb-studio/src/data/mock/viewers.rs @@ -0,0 +1,143 @@ +//! Specialized-viewer fixtures: graph, vector, timeseries, spatial, FTS, sync. +//! +//! The client decodes `SubGraph` node/edge properties and `SearchResult.metadata` +//! as empty, which is why `models::viewers` defines its own display-carrying +//! types instead of reusing client types here. These fixtures are deterministic +//! stand-ins for what the real implementation will decode from raw SQL rows. + +use crate::models::viewers::{ + FtsHit, GraphEdge, GraphNode, SeriesPoint, SpatialFeature, SubGraph, SyncPeer, VectorPoint, +}; + +/// A small connected graph for `collection`: every edge references a node +/// present in `nodes`. Ids and labels are keyed off `collection` so a caller +/// that passes the wrong collection produces visibly different data. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn sub_graph(collection: &str) -> SubGraph { + let nodes = vec![ + GraphNode { + id: format!("{collection}-n1"), + label: format!("{collection}-alice"), + x: 0.0, + y: 0.0, + }, + GraphNode { + id: format!("{collection}-n2"), + label: format!("{collection}-bob"), + x: 1.0, + y: 0.5, + }, + GraphNode { + id: format!("{collection}-n3"), + label: format!("{collection}-carol"), + x: 2.0, + y: 1.0, + }, + ]; + let edges = vec![ + GraphEdge { + id: format!("{collection}-e1"), + from: format!("{collection}-n1"), + to: format!("{collection}-n2"), + label: "follows".into(), + }, + GraphEdge { + id: format!("{collection}-e2"), + from: format!("{collection}-n2"), + to: format!("{collection}-n3"), + label: "follows".into(), + }, + ]; + SubGraph { nodes, edges } +} + +/// The genuinely-empty graph `MockBehavior::Empty` returns for `sub_graph`: a +/// collection with zero nodes is a real graph-viewer outcome, not an absent +/// value, so unlike `record_detail` this must not fold into `sub_graph`'s +/// fixture nodes. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn empty_sub_graph() -> SubGraph { + SubGraph { + nodes: Vec::new(), + edges: Vec::new(), + } +} + +/// A 2D projection of embeddings for `collection`, grouped into a couple of +/// clusters. Ids are keyed off `collection` so a caller that passes the +/// wrong collection produces visibly different data. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn vector_points(collection: &str) -> Vec { + (0..6) + .map(|i| VectorPoint { + id: format!("{collection}-vec-{i}"), + x: i as f32 * 0.3, + y: (i % 3) as f32 * 0.7, + cluster: if i % 2 == 0 { "a" } else { "b" }.into(), + }) + .collect() +} + +/// Samples for one timeseries metric. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn series(metric: &str) -> Vec { + (0..8) + .map(|i| SeriesPoint { + id: format!("{metric}-{i}"), + t: format!("2026-08-08T10:0{i}:00Z"), + value: 10.0 + i as f32, + }) + .collect() +} + +/// Spatial features for `collection`, with placeholder GeoJSON geometry. Ids +/// are keyed off `collection` so a caller that passes the wrong collection +/// produces visibly different data. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn spatial_features(collection: &str) -> Vec { + vec![ + SpatialFeature { + id: format!("{collection}-kl-tower"), + name: "KL Tower".into(), + geometry_json: r#"{"type":"Point","coordinates":[101.7038,3.1528]}"#.into(), + }, + SpatialFeature { + id: format!("{collection}-petronas"), + name: "Petronas Towers".into(), + geometry_json: r#"{"type":"Point","coordinates":[101.7119,3.1579]}"#.into(), + }, + ] +} + +/// Full-text-search hits for `query` within `collection`. Ids are keyed off +/// `collection` so a caller that passes the wrong collection produces +/// visibly different data. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn fts_hits(collection: &str, query: &str) -> Vec { + (0..3) + .map(|i| FtsHit { + id: format!("{collection}-hit-{i}"), + excerpt: format!("...an excerpt in {collection} mentioning {query}..."), + score: format!("0.{}", 9 - i), + }) + .collect() +} + +/// Sync/replication peers. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn sync_peers() -> Vec { + vec![ + SyncPeer { + id: "peer-1".into(), + name: "eu-west".into(), + state: "synced".into(), + lag: "0ms".into(), + }, + SyncPeer { + id: "peer-2".into(), + name: "ap-south".into(), + state: "lagging".into(), + lag: "820ms".into(), + }, + ] +} diff --git a/nodedb-studio/src/data/mock/workbench.rs b/nodedb-studio/src/data/mock/workbench.rs new file mode 100644 index 0000000..36fe64b --- /dev/null +++ b/nodedb-studio/src/data/mock/workbench.rs @@ -0,0 +1,94 @@ +//! Workbench fixtures: a deterministic query result, a short explain plan, +//! and a schema tree with path-like ids. + +use crate::models::explorer::RecordRow; +use crate::models::workbench::{QueryPlan, ResultSet, SchemaNode}; + +/// A deterministic 3-column, 4-row result set. Every query text answers with +/// the same shape today; the real implementation decodes whatever the server +/// returned for `sql`. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn result_set(sql: &str) -> ResultSet { + ResultSet { + columns: vec!["id".into(), "name".into(), "created_at".into()], + rows: (0..4) + .map(|i| RecordRow { + id: format!("row-{i}"), + cells: vec![ + format!("row-{i}"), + format!("item {i}"), + format!("2026-08-0{} 10:0{}:00", (i % 9) + 1, i), + ], + }) + .collect(), + elapsed_ms: 12, + scanned: format!("4 rows for `{sql}`"), + } +} + +/// The genuinely-empty result set `MockBehavior::Empty` returns for +/// `run_query`: zero rows is the most common non-error query outcome, so +/// unlike `record_detail`/`explain` this must not fold into `result_set`'s +/// fixture rows. Columns are shape documentation for the fixture; a zero-row +/// render with headers would need a payload-carrying Empty variant, which +/// does not exist in the current `AsyncState` design. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn empty_result_set(sql: &str) -> ResultSet { + ResultSet { + columns: vec!["id".into(), "name".into(), "created_at".into()], + rows: Vec::new(), + elapsed_ms: 3, + scanned: format!("0 rows for `{sql}`"), + } +} + +/// A short, deterministic EXPLAIN plan for `sql`. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn query_plan(sql: &str) -> QueryPlan { + QueryPlan { + text: format!("Seq Scan on users (cost=0.00..1.04 rows=4)\n -- {sql}"), + } +} + +/// A two-level schema tree (database -> collections -> fields) with +/// path-like ids, so uniqueness is structural rather than accidental. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn schema_tree() -> Vec { + vec![SchemaNode { + id: "db".into(), + label: "db".into(), + kind: "database".into(), + children: vec![ + SchemaNode { + id: "db/users".into(), + label: "users".into(), + kind: "collection".into(), + children: vec![ + SchemaNode { + id: "db/users/id".into(), + label: "id".into(), + kind: "field".into(), + children: Vec::new(), + }, + SchemaNode { + id: "db/users/name".into(), + label: "name".into(), + kind: "field".into(), + children: Vec::new(), + }, + ], + }, + SchemaNode { + id: "db/orders".into(), + label: "orders".into(), + kind: "collection".into(), + children: vec![SchemaNode { + id: "db/orders/id".into(), + label: "id".into(), + kind: "field".into(), + children: Vec::new(), + }], + }, + ], + }] +} diff --git a/nodedb-studio/src/modals/new_connection.rs b/nodedb-studio/src/modals/new_connection.rs index e058ef7..4484b6d 100644 --- a/nodedb-studio/src/modals/new_connection.rs +++ b/nodedb-studio/src/modals/new_connection.rs @@ -1,21 +1,95 @@ //! New-connection modal body. //! -//! Per CLAUDE.md §1 this is a single-engine client: there is NO Engine picker. -//! The Protocol field is also omitted — NodeDB's transport is undecided -//! (CLAUDE.md §2), so the form doesn't assert one. Static in the skeleton. +//! Single-engine client: there is no Engine picker. The Protocol field is +//! omitted too, because NodeDB's transport is unsettled and the form must not +//! assert one. +//! +//! Username is wired to state; the rest of the fields are not. The seam +//! rejects a blank username with `StudioError::MissingUsername` rather than +//! letting `ConnectionBuilder` default it to `admin`, so this form has to +//! collect one and has to refuse to submit without it. Blocking the submit is +//! the point: a rejection the user cannot act on reads as a button that does +//! nothing. +//! +//! Host, port, auth method and password stay presentational because +//! `connect()` takes a name and credentials only, and no seam method persists +//! a new entry, so Save and Test have nothing to call. Wiring them needs a +//! seam addition, which is an ask-first change. + +use std::rc::Rc; +use dioxus::core::spawn_forever; use dioxus::prelude::*; +use crate::services::backend::Backend; +use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; +use crate::state::connections_registry::Credentials; use crate::state::ui::ModalKind; +/// The credentials a submit should send, or `None` when the username is blank. +/// +/// Extracted so the validation the form depends on is testable without a +/// renderer. Whitespace is not a username: the seam trims before its own +/// check, so a form that accepted `" "` would just relay a rejection the +/// user has no way to read as "this field is empty". +fn submit_credentials(username: &str, password: &str) -> Option { + let username = username.trim(); + if username.is_empty() { + return None; + } + Some(Credentials { + username: username.to_string(), + password: (!password.is_empty()).then(|| password.to_string()), + }) +} + #[component] pub fn NewConnectionForm() -> Element { let mut modal = use_context::>>(); + let mut active = use_context::>>(); + let mut connect_error = use_context::>(); + let service = use_context::>(); + + let mut name = use_signal(|| "local-nodedb-dev-2".to_string()); + let mut username = use_signal(String::new); + let mut password = use_signal(String::new); + // Raised by a submit attempt, not by typing: a form that scolds before the + // user has entered anything is noise. + let mut username_missing = use_signal(|| false); + + let submit = move |_| { + let Some(creds) = submit_credentials(&username.peek(), &password.peek()) else { + username_missing.set(true); + return; + }; + username_missing.set(false); + let name = name.peek().clone(); + let service = service.clone(); + // Clear any previous failure so the surface reflects this attempt. + connect_error.set(ConnectError(None)); + // spawn_forever so a Cancel mid-connect cannot cancel the attempt and + // leave the app with neither a session nor an error. + spawn_forever(async move { + let result = service.connect(&name, &creds).await; + let err = apply_connect(&mut active.write(), result); + let connected = err.is_none(); + connect_error.set(ConnectError(err)); + // Close only on success: on failure the form stays open over the + // error so the user can correct the field that caused it. + if connected { + modal.set(None); + } + }); + }; + rsx! { div { class: "modal-body", div { class: "form-field", label { "Name" } - input { value: "local-nodedb-dev-2" } + input { + value: "{name}", + oninput: move |e| name.set(e.value()), + } } div { class: "form-row", div { class: "form-field", label { "Host" } input { value: "localhost" } } @@ -26,8 +100,27 @@ pub fn NewConnectionForm() -> Element { select { option { "Username + password" } option { "Token" } option { "mTLS" } } } div { class: "form-row", - div { class: "form-field", label { "Username" } input { value: "root" } } - div { class: "form-field", label { "Password" } input { r#type: "password", value: "••••••••" } } + div { class: "form-field", + label { "Username" } + input { + value: "{username}", + oninput: move |e| { + username.set(e.value()); + username_missing.set(false); + }, + } + if *username_missing.read() { + div { class: "field-error", "A username is required to connect." } + } + } + div { class: "form-field", + label { "Password" } + input { + r#type: "password", + value: "{password}", + oninput: move |e| password.set(e.value()), + } + } } div { style: "display: flex; gap: 8px; align-items: center; padding-top: 6px;", span { class: "pill info", "i" } @@ -38,7 +131,46 @@ pub fn NewConnectionForm() -> Element { button { class: "btn ghost", onclick: move |_| modal.set(None), "Cancel" } button { class: "btn", "Test" } button { class: "btn", "Save" } - button { class: "btn primary", onclick: move |_| modal.set(None), "Save & connect" } + button { class: "btn primary", onclick: submit, "Save & connect" } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_username_blocks_the_submit() { + assert!(submit_credentials("", "hunter2").is_none()); + } + + /// Whitespace must not pass as a username. The seam trims before its own + /// check, so relaying `" "` would surface "a username is required" with + /// a field that visibly contains something. + #[test] + fn whitespace_username_blocks_the_submit() { + assert!(submit_credentials(" ", "hunter2").is_none()); + } + + #[test] + fn username_is_trimmed_before_it_reaches_the_seam() { + let creds = submit_credentials(" alice ", "").expect("must submit"); + assert_eq!(creds.username, "alice"); + } + + #[test] + fn empty_password_is_none_not_an_empty_string() { + let creds = submit_credentials("alice", "").expect("must submit"); + assert!( + creds.password.is_none(), + "an empty field is an absent password, not a password of length zero" + ); + } + + #[test] + fn password_is_carried_when_present() { + let creds = submit_credentials("alice", "hunter2").expect("must submit"); + assert_eq!(creds.password.as_deref(), Some("hunter2")); + } +} diff --git a/nodedb-studio/src/models/admin.rs b/nodedb-studio/src/models/admin.rs new file mode 100644 index 0000000..306a731 --- /dev/null +++ b/nodedb-studio/src/models/admin.rs @@ -0,0 +1,77 @@ +//! Admin-tier models. String fields mirror the wire, which returns every +//! scalar as a string. `is_superuser` and `enabled` are already plain `bool` +//! here: the real seam implementation will decode the server's "t"/"f" +//! encoding into these fields at that boundary, so views never have to see +//! the wire representation. + +use serde::{Deserialize, Serialize}; + +/// One node in the cluster topology. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClusterNode { + pub id: String, + pub address: String, + pub state: String, + pub raft_groups: String, +} + +/// One Raft consensus group. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RaftGroup { + pub id: String, + pub role: String, + pub leader_id: String, + pub term: String, + pub commit_index: String, + pub last_applied: String, + pub members: String, +} + +/// One shard range and its current leaseholder. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShardRange { + pub id: String, + pub group_id: String, + pub leaseholder: String, + pub replicas: String, + pub qps: String, + pub p99_ms: String, +} + +/// One RBAC user row. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserRow { + pub id: String, + pub username: String, + pub tenant_id: String, + pub roles: String, + pub is_superuser: bool, +} + +/// One row-level-security policy. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RlsPolicy { + pub id: String, + pub name: String, + pub collection: String, + pub kind: String, + pub mode: String, + pub enabled: bool, +} + +/// One audit-log entry. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: String, + pub when: String, + pub actor: String, + pub action: String, + pub target: String, + pub result: String, +} diff --git a/nodedb-studio/src/models/explorer.rs b/nodedb-studio/src/models/explorer.rs new file mode 100644 index 0000000..db10634 --- /dev/null +++ b/nodedb-studio/src/models/explorer.rs @@ -0,0 +1,32 @@ +//! Explorer-tier models: the grouped sidebar, list rows, and the detail panel. + +use serde::{Deserialize, Serialize}; + +use crate::models::collection::{Collection, StorageMode}; + +/// One storage-mode group in the Explorer sidebar. `mode` is the stable key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CollectionGroup { + pub mode: StorageMode, + pub collections: Vec, +} + +/// One row in a viewer's list pane. `cells` are pre-formatted for display and +/// align with the viewer's own column headers. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordRow { + pub id: String, + pub cells: Vec, +} + +/// The detail panel for one record. `body_json` is display JSON produced at the +/// seam, never a raw client value. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordDetail { + pub id: String, + pub title: String, + pub body_json: String, + pub footer: String, +} diff --git a/nodedb-studio/src/models/mod.rs b/nodedb-studio/src/models/mod.rs index 3ace1d9..fa3a72f 100644 --- a/nodedb-studio/src/models/mod.rs +++ b/nodedb-studio/src/models/mod.rs @@ -1,6 +1,12 @@ //! Typed domain models shared across views. These describe *data* (collections, //! databases, notifications); live UI state lives in `crate::state`. +pub mod admin; pub mod cdc; pub mod collection; +pub mod explorer; pub mod notification; +pub mod shell; +pub mod streams; +pub mod viewers; +pub mod workbench; diff --git a/nodedb-studio/src/models/shell.rs b/nodedb-studio/src/models/shell.rs new file mode 100644 index 0000000..308fd86 --- /dev/null +++ b/nodedb-studio/src/models/shell.rs @@ -0,0 +1,25 @@ +//! Shell chrome models: nav-rail badge counts and the statusbar's session +//! summary. Both are single values (not lists), which is why the mock impl +//! cannot use `services::mock_behavior::apply` and instead uses +//! `apply_one`, the single-value counterpart `record_detail` also uses. + +use serde::{Deserialize, Serialize}; + +/// Badge counts shown on the nav rail's Query and Streams entries. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct NavBadges { + pub query: u32, + pub streams: u32, +} + +/// The active session summary shown in the statusbar. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionInfo { + pub database: String, + pub role: String, + pub server_version: String, + pub timezone: String, + pub read_only: bool, +} diff --git a/nodedb-studio/src/models/streams.rs b/nodedb-studio/src/models/streams.rs new file mode 100644 index 0000000..6099439 --- /dev/null +++ b/nodedb-studio/src/models/streams.rs @@ -0,0 +1,67 @@ +//! Streams-tier models beyond CDC (`models::cdc`): the consumer-group session +//! plus materialized views, durable topics, scheduled jobs and LISTEN/NOTIFY. +//! String fields mirror the wire, same convention as `models::admin`. + +use serde::{Deserialize, Serialize}; + +/// A Studio-owned CDC consumer session. Studio never shares a consumer group: +/// committing on someone else's group would advance a production consumer past +/// events it never processed. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StreamSession { + pub stream: String, + pub group: String, +} + +/// One materialized view. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaterializedView { + pub id: String, + pub name: String, + pub source: String, + pub refresh_mode: String, + pub rows: String, +} + +/// One durable, replayable topic. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Topic { + pub id: String, + pub name: String, + pub partitions: String, + pub messages: String, + pub retention: String, + pub consumers: String, + pub lag: String, +} + +/// One cron-style scheduled job. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScheduledJob { + pub id: String, + pub name: String, + pub cron: String, + pub last_status: String, + pub next_run: String, +} + +/// One LISTEN/NOTIFY channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotifyChannel { + pub id: String, + pub name: String, + pub subscribers: String, +} + +/// One message on the LISTEN/NOTIFY tail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotifyMessage { + pub id: String, + pub channel: String, + pub at: String, + pub payload_json: String, +} diff --git a/nodedb-studio/src/models/viewers.rs b/nodedb-studio/src/models/viewers.rs new file mode 100644 index 0000000..1312d54 --- /dev/null +++ b/nodedb-studio/src/models/viewers.rs @@ -0,0 +1,88 @@ +//! Specialized-viewer models: graph, vector, timeseries, spatial, FTS, sync. +//! +//! The client decodes `SubGraph` node/edge properties and `SearchResult.metadata` +//! as empty, so Studio cannot get display fields (labels, coordinates, excerpts) +//! by calling those typed client methods. These models carry the fields the +//! viewers actually render; the real seam implementation populates them by +//! decoding raw SQL rows rather than the client's typed graph/search types. + +use serde::{Deserialize, Serialize}; + +/// One node in a graph viewer. `x`/`y` are a laid-out display position, not +/// stored coordinates. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GraphNode { + pub id: String, + pub label: String, + pub x: f32, + pub y: f32, +} + +/// One edge in a graph viewer. `from`/`to` reference `GraphNode::id`. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GraphEdge { + pub id: String, + pub from: String, + pub to: String, + pub label: String, +} + +/// A graph viewer's full render input: every edge must reference a node +/// present in `nodes`. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SubGraph { + pub nodes: Vec, + pub edges: Vec, +} + +/// One point in a vector viewer's projection. `x`/`y` are a 2D projection of +/// the embedding, not the raw vector. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VectorPoint { + pub id: String, + pub x: f32, + pub y: f32, + pub cluster: String, +} + +/// One sample in a timeseries metric. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SeriesPoint { + pub id: String, + pub t: String, + pub value: f32, +} + +/// One feature in a spatial viewer. `geometry_json` is display GeoJSON +/// produced at the seam, never a raw client value. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpatialFeature { + pub id: String, + pub name: String, + pub geometry_json: String, +} + +/// One full-text-search hit. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FtsHit { + pub id: String, + pub excerpt: String, + pub score: String, +} + +/// One peer in the sync/replication topology. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncPeer { + pub id: String, + pub name: String, + pub state: String, + pub lag: String, +} diff --git a/nodedb-studio/src/models/workbench.rs b/nodedb-studio/src/models/workbench.rs new file mode 100644 index 0000000..042f1e0 --- /dev/null +++ b/nodedb-studio/src/models/workbench.rs @@ -0,0 +1,34 @@ +//! Workbench models: result sets, plans, and the schema tree. + +use serde::{Deserialize, Serialize}; + +use crate::models::explorer::RecordRow; + +/// One page of query output. Pagination is the seam's responsibility: the +/// client buffers whole result sets, so the real implementation emits +/// LIMIT/OFFSET rather than holding a cursor. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResultSet { + pub columns: Vec, + pub rows: Vec, + pub elapsed_ms: u32, + pub scanned: String, +} + +/// The query planner's EXPLAIN output for one statement. +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QueryPlan { + pub text: String, +} + +/// One node in the schema tree (database / collection / field, recursively). +#[allow(dead_code)] // SEAM-UNWIRED +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SchemaNode { + pub id: String, + pub label: String, + pub kind: String, + pub children: Vec, +} diff --git a/nodedb-studio/src/routes.rs b/nodedb-studio/src/routes.rs index fc76eaf..2b8c619 100644 --- a/nodedb-studio/src/routes.rs +++ b/nodedb-studio/src/routes.rs @@ -1,7 +1,7 @@ //! Studio-internal routing. //! //! Routing exists only in the connected state — `App` mounts the `Router` -//! inside `Studio`, never in the Connection Manager (CLAUDE.md §5). All routes +//! inside `Studio`, never in the Connection Manager. All routes //! share `StudioLayout`, which renders the persistent chrome (rail, topbar, //! statusbar) around the content `Outlet`. @@ -123,7 +123,8 @@ fn StudioLayout() -> Element { }); // Global keyboard shortcuts. Attached to the focused root so it works - // without document-level JS (CLAUDE.md §4). ⌘K / ⌘D act only while + // without document-level JS, which the desktop shell does not give us. + // ⌘K / ⌘D act only while // connected (always true here); ⌘, opens Preferences; Esc closes overlays. let on_key = move |e: KeyboardEvent| { let meta = e.modifiers().meta() || e.modifiers().ctrl(); diff --git a/nodedb-studio/src/services/admin_data.rs b/nodedb-studio/src/services/admin_data.rs new file mode 100644 index 0000000..8df6592 --- /dev/null +++ b/nodedb-studio/src/services/admin_data.rs @@ -0,0 +1,196 @@ +//! Admin-tier reads at the backend seam: cluster, raft, shards, RBAC, RLS, +//! audit. Every one has a real server-side source, so these signatures are +//! designed for a real implementation rather than as permanent mock stubs. + +use async_trait::async_trait; + +use crate::models::admin::{AuditEntry, ClusterNode, RaftGroup, RlsPolicy, ShardRange, UserRow}; +use crate::services::error::StudioError; + +#[async_trait(?Send)] +pub trait AdminData { + /// Cluster topology: one row per node. + #[allow(dead_code)] // SEAM-UNWIRED + async fn cluster_nodes(&self) -> Result, StudioError>; + + /// Raft groups for the cluster. + #[allow(dead_code)] // SEAM-UNWIRED + async fn raft_groups(&self) -> Result, StudioError>; + + /// Shard ranges and their leaseholders. + #[allow(dead_code)] // SEAM-UNWIRED + async fn shard_ranges(&self) -> Result, StudioError>; + + /// RBAC: all users in the tenant. + #[allow(dead_code)] // SEAM-UNWIRED + async fn users(&self) -> Result, StudioError>; + + /// Row-level-security policies. + #[allow(dead_code)] // SEAM-UNWIRED + async fn rls_policies(&self) -> Result, StudioError>; + + /// Audit log entries. + #[allow(dead_code)] // SEAM-UNWIRED + async fn audit_entries(&self) -> Result, StudioError>; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::async_state::AsyncState; + use crate::services::connection_service::MockConnectionService; + + #[tokio::test] + async fn every_admin_read_has_unique_ids() { + let svc = MockConnectionService::ready(); + let nodes = svc.cluster_nodes().await.expect("nodes"); + let groups = svc.raft_groups().await.expect("raft"); + let shards = svc.shard_ranges().await.expect("shards"); + let users = svc.users().await.expect("users"); + let policies = svc.rls_policies().await.expect("rls"); + let audit = svc.audit_entries().await.expect("audit"); + + assert_unique(nodes.iter().map(|x| x.id.as_str()), "cluster_nodes"); + assert_unique(groups.iter().map(|x| x.id.as_str()), "raft_groups"); + assert_unique(shards.iter().map(|x| x.id.as_str()), "shard_ranges"); + assert_unique(users.iter().map(|x| x.id.as_str()), "users"); + assert_unique(policies.iter().map(|x| x.id.as_str()), "rls_policies"); + assert_unique(audit.iter().map(|x| x.id.as_str()), "audit_entries"); + } + + fn assert_unique<'a>(it: impl Iterator, what: &str) { + let mut v: Vec<&str> = it.collect(); + let total = v.len(); + assert!(total > 0, "{what} fixture must not be empty"); + v.sort_unstable(); + v.dedup(); + assert_eq!(total, v.len(), "{what} ids must be unique"); + } + + // Each method gets its own empty/erroring pair rather than one combined + // check per behaviour: `apply(self.behavior, mock::x)` and a mis-wired + // `Ok(mock::x())` both satisfy a single shared assertion, so every method + // needs its own proof that it actually reads `self.behavior`. + + #[tokio::test] + async fn cluster_nodes_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.cluster_nodes().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn cluster_nodes_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.cluster_nodes().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn raft_groups_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.raft_groups().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn raft_groups_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.raft_groups().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn shard_ranges_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.shard_ranges().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn shard_ranges_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.shard_ranges().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn users_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.users().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn users_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.users().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn rls_policies_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.rls_policies().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn rls_policies_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.rls_policies().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn audit_entries_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.audit_entries().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn audit_entries_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.audit_entries().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn users_fixture_marks_admin_superuser_and_alice_not() { + let svc = MockConnectionService::ready(); + let users = svc.users().await.expect("users"); + let admin = users + .iter() + .find(|u| u.username == "admin") + .expect("fixture has an `admin` user"); + let alice = users + .iter() + .find(|u| u.username == "alice") + .expect("fixture has an `alice` user"); + assert!(admin.is_superuser, "admin fixture must be a superuser"); + assert!(!alice.is_superuser, "alice fixture must not be a superuser"); + } + + #[tokio::test] + async fn rls_policies_fixture_enabled_flags_match_intent() { + let svc = MockConnectionService::ready(); + let policies = svc.rls_policies().await.expect("rls"); + let tenant_isolation = policies + .iter() + .find(|p| p.name == "tenant_isolation") + .expect("fixture has a `tenant_isolation` policy"); + let pii_masking = policies + .iter() + .find(|p| p.name == "pii_masking") + .expect("fixture has a `pii_masking` policy"); + assert!( + tenant_isolation.enabled, + "tenant_isolation must be enabled in the fixture" + ); + assert!( + !pii_masking.enabled, + "pii_masking must be disabled in the fixture" + ); + } +} diff --git a/nodedb-studio/src/services/async_state.rs b/nodedb-studio/src/services/async_state.rs index 28b150c..d041846 100644 --- a/nodedb-studio/src/services/async_state.rs +++ b/nodedb-studio/src/services/async_state.rs @@ -4,6 +4,10 @@ //! testable. Every later wiring phase maps a `use_resource` result into an //! `AsyncState` via `from_value` and hands it to the `AsyncView` component. +use crate::models::explorer::RecordDetail; +use crate::models::shell::{NavBadges, SessionInfo}; +use crate::models::viewers::SubGraph; +use crate::models::workbench::{QueryPlan, ResultSet}; use crate::services::error::StudioError; /// Anything that can report emptiness, so `from_value` can distinguish a @@ -18,6 +22,56 @@ impl IsEmpty for Vec { } } +// Single-value seam reads have no "empty" shape: a fetched value is never +// "empty", it either arrived or it errored (`MockBehavior::Empty` folds into +// `Ready` for these — see `record_detail`'s precedent). Each impl is listed +// explicitly, deliberately not a blanket `impl IsEmpty for T`, so a future +// single-value model must opt in here rather than silently inheriting a +// meaning that may not fit it. +impl IsEmpty for SessionInfo { + fn is_empty(&self) -> bool { + false + } +} + +impl IsEmpty for NavBadges { + fn is_empty(&self) -> bool { + false + } +} + +impl IsEmpty for RecordDetail { + fn is_empty(&self) -> bool { + false + } +} + +impl IsEmpty for QueryPlan { + fn is_empty(&self) -> bool { + false + } +} + +// `ResultSet` and `SubGraph` are also single fetched values, but unlike the +// four above they wrap a list (rows / nodes) whose emptiness IS a real, +// common outcome — "your query returned no rows" for a workbench, or "this +// collection has no nodes" for a graph viewer. Folding `MockBehavior::Empty` +// into `Ready` for these would make `AsyncState::Empty` unreachable for the +// query and graph panes, so they report their own emptiness instead of the +// blanket `false` above (see `apply_one_or_empty` in `mock_behavior`, which +// gives the mock seam methods a way to actually deliver an empty payload). +impl IsEmpty for ResultSet { + fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +impl IsEmpty for SubGraph { + fn is_empty(&self) -> bool { + self.nodes.is_empty() + } +} + /// The canonical, unit-tested mapping from a `use_resource` read to the four UI /// states. Wired views call `from_value` directly (cloning the resource value /// out of its guard — `StudioError` is `Clone`) and then drive `AsyncView` via @@ -128,6 +182,8 @@ impl AsyncState { #[cfg(test)] mod tests { use super::*; + use crate::models::explorer::RecordRow; + use crate::models::viewers::GraphNode; #[test] fn async_state_none_is_loading() { @@ -217,6 +273,97 @@ mod tests { ); } + #[test] + fn single_value_seam_models_are_loaded_not_empty() { + // Before their `IsEmpty` impls existed, these four single-value seam + // models could not satisfy `AsyncState::from_value`'s `T: IsEmpty` + // bound at all, so they could never be rendered through `AsyncView`. + // Each must map a fetched value straight to `Loaded`, never `Empty`: + // none of the four has a meaningful "empty" shape. + let session_info = AsyncState::from_value(Some(Ok(SessionInfo { + database: String::new(), + role: String::new(), + server_version: String::new(), + timezone: String::new(), + read_only: false, + }))); + assert!(matches!(session_info, AsyncState::Loaded(_))); + + let nav_badges = AsyncState::from_value(Some(Ok(NavBadges { + query: 0, + streams: 0, + }))); + assert!(matches!(nav_badges, AsyncState::Loaded(_))); + + let record_detail = AsyncState::from_value(Some(Ok(RecordDetail { + id: String::new(), + title: String::new(), + body_json: String::new(), + footer: String::new(), + }))); + assert!(matches!(record_detail, AsyncState::Loaded(_))); + + let query_plan = AsyncState::from_value(Some(Ok(QueryPlan { + text: String::new(), + }))); + assert!(matches!(query_plan, AsyncState::Loaded(_))); + } + + #[test] + fn zero_row_result_set_is_empty() { + // Unlike the four single-value models above, `ResultSet` wraps a + // list: a query that returns zero rows is the most common + // non-error workbench outcome and must reach `AsyncState::Empty`, + // not `Loaded` with an empty table. + let result_set = AsyncState::from_value(Some(Ok(ResultSet { + columns: vec!["id".to_string()], + rows: Vec::new(), + elapsed_ms: 3, + scanned: "0 rows".to_string(), + }))); + assert!(matches!(result_set, AsyncState::Empty)); + } + + #[test] + fn non_empty_result_set_is_loaded() { + let result_set = AsyncState::from_value(Some(Ok(ResultSet { + columns: vec!["id".to_string()], + rows: vec![RecordRow { + id: "1".to_string(), + cells: vec!["1".to_string()], + }], + elapsed_ms: 3, + scanned: "1 row".to_string(), + }))); + assert!(matches!(result_set, AsyncState::Loaded(_))); + } + + #[test] + fn zero_node_sub_graph_is_empty() { + // A collection with no nodes is a real outcome for the graph viewer + // and must reach `AsyncState::Empty`, not `Loaded` with an empty + // graph. + let sub_graph = AsyncState::from_value(Some(Ok(SubGraph { + nodes: Vec::new(), + edges: Vec::new(), + }))); + assert!(matches!(sub_graph, AsyncState::Empty)); + } + + #[test] + fn non_empty_sub_graph_is_loaded() { + let sub_graph = AsyncState::from_value(Some(Ok(SubGraph { + nodes: vec![GraphNode { + id: "n1".to_string(), + label: "alice".to_string(), + x: 0.0, + y: 0.0, + }], + edges: Vec::new(), + }))); + assert!(matches!(sub_graph, AsyncState::Loaded(_))); + } + #[test] fn project_filters_loaded_and_redrives_empty() { // A filter that keeps elements -> Loaded with the filtered set. diff --git a/nodedb-studio/src/services/backend.rs b/nodedb-studio/src/services/backend.rs index 0095a14..9e57413 100644 --- a/nodedb-studio/src/services/backend.rs +++ b/nodedb-studio/src/services/backend.rs @@ -5,9 +5,19 @@ //! Adding a new domain trait later means extending this bound and implementing the //! trait on the mock + stub — additive, never a reshape of existing methods. +use crate::services::admin_data::AdminData; use crate::services::connection_service::ConnectionService; +use crate::services::explorer_data::ExplorerData; use crate::services::streams_data::StreamsData; +use crate::services::viewers_data::ViewersData; +use crate::services::workbench_data::WorkbenchData; -pub trait Backend: ConnectionService + StreamsData {} +pub trait Backend: + ConnectionService + StreamsData + ExplorerData + AdminData + WorkbenchData + ViewersData +{ +} -impl Backend for T {} +impl + Backend for T +{ +} diff --git a/nodedb-studio/src/services/connection_service.rs b/nodedb-studio/src/services/connection_service.rs index f1fab73..8ad7c6a 100644 --- a/nodedb-studio/src/services/connection_service.rs +++ b/nodedb-studio/src/services/connection_service.rs @@ -12,12 +12,27 @@ use std::rc::Rc; use async_trait::async_trait; use crate::data::mock; +use crate::models::admin::{AuditEntry, ClusterNode, RaftGroup, RlsPolicy, ShardRange, UserRow}; use crate::models::cdc::CdcRow; +use crate::models::explorer::{CollectionGroup, RecordDetail, RecordRow}; use crate::models::notification::Notification; +use crate::models::shell::{NavBadges, SessionInfo}; +use crate::models::streams::{ + MaterializedView, NotifyChannel, NotifyMessage, ScheduledJob, StreamSession, Topic, +}; +use crate::models::viewers::{ + FtsHit, SeriesPoint, SpatialFeature, SubGraph, SyncPeer, VectorPoint, +}; +use crate::models::workbench::{QueryPlan, ResultSet, SchemaNode}; +use crate::services::admin_data::AdminData; use crate::services::error::StudioError; +use crate::services::explorer_data::ExplorerData; +use crate::services::mock_behavior::{MockBehavior, apply, apply_one, apply_one_or_empty}; use crate::services::streams_data::{StreamsData, cdc_rows_from_mock}; +use crate::services::viewers_data::ViewersData; +use crate::services::workbench_data::WorkbenchData; use crate::state::connection::ActiveConnection; -use crate::state::connections_registry::SavedConnection; +use crate::state::connections_registry::{Credentials, SavedConnection}; /// Async because the real client talks to NodeDB over the network. The Dioxus /// runtime is single-threaded, so `?Send` is correct (and `use_resource` has no @@ -32,27 +47,31 @@ pub trait ConnectionService { /// The full notification feed (capability gating happens at render time). async fn notifications(&self) -> Result, StudioError>; - /// Open a session by saved-connection name. `StudioError::NotConnected` if - /// the name is unknown or the connection is offline. - async fn connect(&self, name: &str) -> Result; + /// Open a session by saved-connection name using an explicit identity. + /// `StudioError::MissingUsername` if the username is blank; + /// `StudioError::NotConnected` if the name is unknown or offline. + async fn connect( + &self, + name: &str, + creds: &Credentials, + ) -> Result; /// Mark every notification read. A seam write: the real client persists this /// server-side; the mock persists it in-process so the unread badge does not /// revert on reload (POP-03). async fn mark_all_read(&self) -> Result<(), StudioError>; -} -/// Drives which result the mock returns, so every screen's four async states are -/// reachable in demos and tests. -// Variants are public API for demos and tests; not all are used in the app binary. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum MockBehavior { - #[default] - Ready, - #[allow(dead_code)] - Empty, - #[allow(dead_code)] - Erroring, + /// Badge counts for the nav rail's Query and Streams entries. + #[allow(dead_code)] // SEAM-UNWIRED + async fn nav_badges(&self) -> Result; + + /// The active session summary shown in the statusbar. + #[allow(dead_code)] // SEAM-UNWIRED + async fn session_info(&self) -> Result; + + /// All databases visible on the active connection. + #[allow(dead_code)] // SEAM-UNWIRED + async fn databases(&self) -> Result, StudioError>; } /// Hardcoded implementation used by the skeleton. Data is identical to before; @@ -66,6 +85,12 @@ pub enum MockBehavior { pub struct MockConnectionService { behavior: MockBehavior, all_read: Rc>, + /// Offset the next `cdc_batch` reads from. Only `commit_stream_offsets` + /// advances it, mirroring the server: reads are idempotent. + cdc_committed: Rc>, + /// How far the most recent `cdc_batch` read. Commit promotes this into + /// `cdc_committed`. + cdc_read_end: Rc>, } // Constructors are public API for demos and tests; not all are used in the app binary. @@ -75,6 +100,8 @@ impl MockConnectionService { Self { behavior: MockBehavior::Ready, all_read: Rc::default(), + cdc_committed: Rc::default(), + cdc_read_end: Rc::default(), } } /// Every read returns an empty collection. @@ -83,6 +110,8 @@ impl MockConnectionService { Self { behavior: MockBehavior::Empty, all_read: Rc::default(), + cdc_committed: Rc::default(), + cdc_read_end: Rc::default(), } } /// Every read fails with a retriable server error. @@ -91,6 +120,34 @@ impl MockConnectionService { Self { behavior: MockBehavior::Erroring, all_read: Rc::default(), + cdc_committed: Rc::default(), + cdc_read_end: Rc::default(), + } + } + /// Every read resolves after `d`, so the Loading state is observable. + #[allow(dead_code)] + pub fn delayed(d: std::time::Duration) -> Self { + Self { + behavior: MockBehavior::Delayed(d), + all_read: Rc::default(), + cdc_committed: Rc::default(), + cdc_read_end: Rc::default(), + } + } + + /// Test-only: a clone that shares this instance's CDC cursor cells but + /// answers reads with a different `MockBehavior`. Lets a test simulate a + /// single session whose connection recovers after an error (or stops + /// being empty), without losing whatever the cursor already recorded — + /// which is exactly the scenario the commit-after-a-failed-read + /// regression needs to observe. + #[cfg(test)] + pub(crate) fn with_shared_state(&self, behavior: MockBehavior) -> Self { + Self { + behavior, + all_read: self.all_read.clone(), + cdc_committed: self.cdc_committed.clone(), + cdc_read_end: self.cdc_read_end.clone(), } } } @@ -98,43 +155,237 @@ impl MockConnectionService { #[async_trait(?Send)] impl ConnectionService for MockConnectionService { async fn list_connections(&self) -> Result, StudioError> { - Ok(mock::connections()) + apply(self.behavior, mock::connections).await } async fn notifications(&self) -> Result, StudioError> { - let mut notifs = mock::notifications(); - if self.all_read.get() { - for n in &mut notifs { - n.unread = false; + let all_read = self.all_read.get(); + apply(self.behavior, move || { + let mut notifs = mock::notifications(); + if all_read { + for n in &mut notifs { + n.unread = false; + } } - } - Ok(notifs) + notifs + }) + .await } async fn mark_all_read(&self) -> Result<(), StudioError> { + // Route the write through the same behaviour switch as every read, so + // the failure path is reachable in tests. + apply_one(self.behavior, || ()).await?; self.all_read.set(true); Ok(()) } - async fn connect(&self, name: &str) -> Result { - mock::connections() - .into_iter() - .find(|c| c.name == name) - .and_then(|c| c.open()) - .ok_or(StudioError::NotConnected) + async fn connect( + &self, + name: &str, + creds: &Credentials, + ) -> Result { + // The blank-username guard must run before any behaviour branch: a + // caller with bad input gets `MissingUsername`, never a behaviour + // error, even when the service is configured `Erroring`. + if creds.username.trim().is_empty() { + return Err(StudioError::MissingUsername); + } + let name = name.to_string(); + let session = apply_one(self.behavior, move || { + mock::connections() + .into_iter() + .find(|c| c.name == name) + .and_then(|c| c.open()) + }) + .await?; + session.ok_or(StudioError::NotConnected) + } + + async fn nav_badges(&self) -> Result { + apply_one(self.behavior, mock::nav_badges).await + } + + async fn session_info(&self) -> Result { + apply_one(self.behavior, mock::session_info).await + } + + async fn databases(&self) -> Result, StudioError> { + apply(self.behavior, mock::databases).await } } #[async_trait(?Send)] impl StreamsData for MockConnectionService { async fn cdc_feed(&self) -> Result, StudioError> { - match self.behavior { - MockBehavior::Ready => Ok(cdc_rows_from_mock()), - MockBehavior::Empty => Ok(Vec::new()), - MockBehavior::Erroring => Err(StudioError::from( - nodedb_client::NodeDbError::node_unreachable("mock"), - )), - } + apply(self.behavior, cdc_rows_from_mock).await + } + + async fn open_stream_session(&self, stream: &str) -> Result { + Ok(StreamSession { + stream: stream.to_string(), + group: format!("studio_{stream}"), + }) + } + + async fn cdc_batch( + &self, + _session: &StreamSession, + limit: usize, + ) -> Result, StudioError> { + let start = self.cdc_committed.get(); + let batch: Vec = cdc_rows_from_mock() + .into_iter() + .skip(start) + .take(limit) + .collect(); + // Run the behaviour first: `Erroring` returns before the cursor is + // touched (the `?`), and `Empty` delivers no rows. Only what was + // actually delivered may move `cdc_read_end` — otherwise a commit + // after a failed or empty read would promote a phantom offset into + // `cdc_committed` and silently skip events the caller never saw. + let delivered = apply(self.behavior, move || batch).await?; + // Do NOT advance the committed offset here: re-reading without a + // commit must return the same rows. + self.cdc_read_end.set(start + delivered.len()); + Ok(delivered) + } + + async fn commit_stream_offsets(&self, _session: &StreamSession) -> Result<(), StudioError> { + self.cdc_committed.set(self.cdc_read_end.get()); + Ok(()) + } + + async fn close_stream_session(&self, _session: &StreamSession) -> Result<(), StudioError> { + self.cdc_committed.set(0); + self.cdc_read_end.set(0); + Ok(()) + } + + async fn materialized_views(&self) -> Result, StudioError> { + apply(self.behavior, mock::materialized_views).await + } + + async fn topics(&self) -> Result, StudioError> { + apply(self.behavior, mock::topics).await + } + + async fn scheduled_jobs(&self) -> Result, StudioError> { + apply(self.behavior, mock::scheduled_jobs).await + } + + async fn notify_channels(&self) -> Result, StudioError> { + apply(self.behavior, mock::notify_channel_rows).await + } + + async fn notify_messages(&self) -> Result, StudioError> { + apply(self.behavior, mock::notify_message_rows).await + } +} + +#[async_trait(?Send)] +impl ExplorerData for MockConnectionService { + async fn collection_groups(&self) -> Result, StudioError> { + apply(self.behavior, mock::collection_groups).await + } + + async fn records(&self, collection: &str) -> Result, StudioError> { + let c = collection.to_string(); + apply(self.behavior, move || mock::records(&c)).await + } + + async fn record_detail(&self, collection: &str, id: &str) -> Result { + let c = collection.to_string(); + let i = id.to_string(); + apply_one(self.behavior, move || mock::record_detail(&c, &i)).await + } +} + +#[async_trait(?Send)] +impl AdminData for MockConnectionService { + async fn cluster_nodes(&self) -> Result, StudioError> { + apply(self.behavior, mock::cluster_nodes).await + } + + async fn raft_groups(&self) -> Result, StudioError> { + apply(self.behavior, mock::raft_groups).await + } + + async fn shard_ranges(&self) -> Result, StudioError> { + apply(self.behavior, mock::shard_ranges).await + } + + async fn users(&self) -> Result, StudioError> { + apply(self.behavior, mock::users).await + } + + async fn rls_policies(&self) -> Result, StudioError> { + apply(self.behavior, mock::rls_policies).await + } + + async fn audit_entries(&self) -> Result, StudioError> { + apply(self.behavior, mock::audit_entries).await + } +} + +#[async_trait(?Send)] +impl WorkbenchData for MockConnectionService { + async fn run_query(&self, sql: &str) -> Result { + let s = sql.to_string(); + let s2 = s.clone(); + apply_one_or_empty( + self.behavior, + move || mock::result_set(&s), + move || mock::empty_result_set(&s2), + ) + .await + } + + async fn explain(&self, sql: &str) -> Result { + let s = sql.to_string(); + apply_one(self.behavior, move || mock::query_plan(&s)).await + } + + async fn schema_tree(&self) -> Result, StudioError> { + apply(self.behavior, mock::schema_tree).await + } +} + +#[async_trait(?Send)] +impl ViewersData for MockConnectionService { + async fn sub_graph(&self, collection: &str) -> Result { + let c = collection.to_string(); + apply_one_or_empty( + self.behavior, + move || mock::sub_graph(&c), + mock::empty_sub_graph, + ) + .await + } + + async fn vector_points(&self, collection: &str) -> Result, StudioError> { + let c = collection.to_string(); + apply(self.behavior, move || mock::vector_points(&c)).await + } + + async fn series(&self, metric: &str) -> Result, StudioError> { + let m = metric.to_string(); + apply(self.behavior, move || mock::series(&m)).await + } + + async fn spatial_features(&self, collection: &str) -> Result, StudioError> { + let c = collection.to_string(); + apply(self.behavior, move || mock::spatial_features(&c)).await + } + + async fn fts_hits(&self, collection: &str, query: &str) -> Result, StudioError> { + let c = collection.to_string(); + let q = query.to_string(); + apply(self.behavior, move || mock::fts_hits(&c, &q)).await + } + + async fn sync_peers(&self) -> Result, StudioError> { + apply(self.behavior, mock::sync_peers).await } } @@ -149,7 +400,9 @@ mod tests { let svc = MockConnectionService::ready(); let before = svc.notifications().await.expect("mock infallible"); assert!(before.iter().any(|n| n.unread), "fixture has unread items"); - svc.mark_all_read().await.expect("mock write infallible"); + svc.mark_all_read() + .await + .expect("ready mock write succeeds"); let after = svc.notifications().await.expect("mock infallible"); assert!( after.iter().all(|n| !n.unread), @@ -177,23 +430,255 @@ mod tests { assert!(!conns.is_empty()); } + #[tokio::test] + async fn list_connections_empty_behavior_returns_no_rows() { + let svc = MockConnectionService::empty(); + let conns = svc.list_connections().await.expect("empty behaviour is Ok"); + assert!(conns.is_empty()); + } + + #[tokio::test] + async fn list_connections_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let err = svc + .list_connections() + .await + .expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + + #[tokio::test] + async fn notifications_empty_behavior_returns_no_rows() { + let svc = MockConnectionService::empty(); + let notifs = svc.notifications().await.expect("empty behaviour is Ok"); + assert!(notifs.is_empty()); + } + + #[tokio::test] + async fn notifications_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let err = svc.notifications().await.expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + #[tokio::test] async fn mock_connect_known_name_returns_session() { let svc = MockConnectionService::ready(); + let creds = Credentials { + username: "alice".into(), + password: None, + }; // `staging-cluster` is a connectable Online mock connection (data/mock.rs). - let session = svc.connect("staging-cluster").await; + let session = svc.connect("staging-cluster", &creds).await; assert!(session.is_ok()); } #[tokio::test] async fn mock_connect_unknown_name_is_not_connected() { let svc = MockConnectionService::ready(); + let creds = Credentials { + username: "alice".into(), + password: None, + }; assert!(matches!( - svc.connect("does-not-exist").await, + svc.connect("does-not-exist", &creds).await, Err(StudioError::NotConnected) )); } + #[tokio::test] + async fn connect_rejects_blank_username() { + let svc = MockConnectionService::ready(); + let creds = Credentials { + username: " ".into(), + password: None, + }; + let out = svc.connect("local-dev", &creds).await; + assert!( + matches!(out, Err(StudioError::MissingUsername)), + "blank username must be rejected, never defaulted to admin" + ); + } + + #[tokio::test] + async fn connect_accepts_explicit_username() { + let svc = MockConnectionService::ready(); + let name = mock::connections() + .first() + .map(|c| c.name.clone()) + .expect("fixture must have at least one connection"); + let creds = Credentials { + username: "alice".into(), + password: None, + }; + assert!(svc.connect(&name, &creds).await.is_ok()); + } + + #[tokio::test] + async fn connect_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let creds = Credentials { + username: "alice".into(), + password: None, + }; + let err = svc + .connect("staging-cluster", &creds) + .await + .expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + + #[tokio::test] + async fn connect_delayed_still_resolves() { + let svc = MockConnectionService::delayed(std::time::Duration::from_millis(5)); + let creds = Credentials { + username: "alice".into(), + password: None, + }; + assert!(svc.connect("staging-cluster", &creds).await.is_ok()); + } + + #[tokio::test] + async fn connect_checks_missing_username_before_consulting_behavior() { + // A blank username must surface `MissingUsername` even when the + // service is configured `Erroring` — the guard must not become + // skippable by routing `connect` through the behaviour matcher. + let svc = MockConnectionService::erroring(); + let creds = Credentials { + username: " ".into(), + password: None, + }; + let out = svc.connect("staging-cluster", &creds).await; + assert!( + matches!(out, Err(StudioError::MissingUsername)), + "blank username must win over the configured Erroring behavior" + ); + } + + #[tokio::test] + async fn session_info_populates_the_statusbar() { + let svc = MockConnectionService::ready(); + let s = svc.session_info().await.expect("session info"); + assert!(!s.database.is_empty()); + assert!(!s.role.is_empty()); + assert!(!s.server_version.is_empty()); + assert!(!s.timezone.is_empty()); + } + + #[tokio::test] + async fn session_info_reports_the_fixture_values() { + // Independently-authored expectations (not derived from the call under + // test), so this can actually fail if the fixture drifts. + let svc = MockConnectionService::ready(); + let s = svc.session_info().await.expect("session info"); + assert_eq!(s.database, "analytics"); + assert_eq!(s.role, "admin"); + assert!(!s.read_only, "fixture session is a read-write admin"); + } + + #[tokio::test] + async fn session_info_empty_behavior_still_returns_the_fixture() { + // Single-value reads have no "empty" shape, so Empty folds into Ready + // — matching the `record_detail` precedent. + let svc = MockConnectionService::empty(); + let s = svc.session_info().await.expect("empty folds into ready"); + assert_eq!(s.database, "analytics"); + } + + #[tokio::test] + async fn session_info_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let err = svc.session_info().await.expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + + #[tokio::test] + async fn session_info_delayed_still_resolves() { + let svc = MockConnectionService::delayed(std::time::Duration::from_millis(5)); + let s = svc.session_info().await.expect("delayed still resolves"); + assert_eq!(s.role, "admin"); + } + + #[tokio::test] + async fn nav_badges_come_from_the_seam() { + let svc = MockConnectionService::ready(); + let b = svc.nav_badges().await.expect("badges"); + assert!(b.query > 0 || b.streams > 0, "fixture should show badges"); + } + + #[tokio::test] + async fn nav_badges_reports_the_fixture_counts() { + let svc = MockConnectionService::ready(); + let b = svc.nav_badges().await.expect("badges"); + assert_eq!(b.query, 3); + assert_eq!(b.streams, 6); + } + + #[tokio::test] + async fn nav_badges_empty_behavior_still_returns_the_fixture() { + let svc = MockConnectionService::empty(); + let b = svc.nav_badges().await.expect("empty folds into ready"); + assert!(b.query > 0 || b.streams > 0); + } + + #[tokio::test] + async fn nav_badges_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let err = svc.nav_badges().await.expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + + #[tokio::test] + async fn nav_badges_delayed_still_resolves() { + let svc = MockConnectionService::delayed(std::time::Duration::from_millis(5)); + let b = svc.nav_badges().await.expect("delayed still resolves"); + assert_eq!(b.query, 3); + } + + #[tokio::test] + async fn databases_are_unique() { + let svc = MockConnectionService::ready(); + let mut dbs = svc.databases().await.expect("databases"); + let total = dbs.len(); + dbs.sort(); + dbs.dedup(); + assert_eq!(total, dbs.len()); + } + + #[tokio::test] + async fn databases_fixture_has_more_than_one_entry() { + // Pins the precondition `databases_are_unique` relies on: with a + // single-entry fixture the dedup check above could never fire. + let svc = MockConnectionService::ready(); + let dbs = svc.databases().await.expect("databases"); + assert!( + dbs.len() > 1, + "fixture must list more than one database for the dedup check to be meaningful" + ); + assert!(dbs.contains(&"analytics".to_string())); + } + + #[tokio::test] + async fn databases_empty_behavior_returns_no_rows() { + let svc = MockConnectionService::empty(); + let dbs = svc.databases().await.expect("mock databases is infallible"); + assert!(dbs.is_empty()); + } + + #[tokio::test] + async fn databases_erroring_is_retriable_error() { + let svc = MockConnectionService::erroring(); + let err = svc.databases().await.expect_err("erroring must fail"); + assert!(err.is_retriable()); + } + + #[tokio::test] + async fn databases_delayed_still_returns_the_fixture() { + let svc = MockConnectionService::delayed(std::time::Duration::from_millis(5)); + let dbs = svc.databases().await.expect("delayed still resolves"); + assert!(!dbs.is_empty()); + } + #[tokio::test] async fn cdc_feed_ready_is_loaded() { let svc = MockConnectionService::ready(); @@ -219,8 +704,8 @@ mod tests { // Verifies the two-step transition pattern: set Loading, await the read, // set Loaded from the result. This test has no Dioxus signals/guards (unit-level), // so it only demonstrates the transition logic; the actual no-guard-across-await - // discipline is exercised by the real streaming view (later task) and enforced - // by the AGENTS.md convention. + // discipline is exercised by real streaming views and enforced by the + // AGENTS.md convention. #[tokio::test] async fn delayed_read_transitions_loading_to_loaded_without_guard() { let svc = MockConnectionService::ready(); @@ -232,4 +717,24 @@ mod tests { latest = AsyncState::from_value(Some(result)); assert!(matches!(latest, AsyncState::Loaded(_))); } + /// The write must be able to fail, or the popover's failure path is dead + /// code. Erroring must reject the write AND leave the read unchanged, so a + /// caller that mutates local state only after Ok cannot end up ahead of the + /// backend. + #[tokio::test] + async fn mark_all_read_erroring_rejects_and_leaves_feed_unread() { + let svc = MockConnectionService::erroring(); + assert!( + svc.mark_all_read().await.is_err(), + "erroring write must fail" + ); + // The read also errors under this behaviour, so probe persisted state + // through a ready view of the same shared cell instead. + let ready = svc.with_shared_state(MockBehavior::Ready); + let feed = ready.notifications().await.expect("ready read"); + assert!( + feed.iter().any(|n| n.unread), + "a failed write must not clear the persisted unread flags" + ); + } } diff --git a/nodedb-studio/src/services/decode.rs b/nodedb-studio/src/services/decode.rs new file mode 100644 index 0000000..bbd3de1 --- /dev/null +++ b/nodedb-studio/src/services/decode.rs @@ -0,0 +1,158 @@ +//! Column-addressed decoding of tabular results into typed `models/` structs. +//! +//! Seam methods return Studio models, but the real implementation will build +//! them from a `QueryResult{columns, rows}`. `Table` is that shape expressed in +//! Studio's own terms, so decoders are unit-testable with no client or server. +//! +//! Every value arrives as a string on this path: `SHOW` and `DESCRIBE` return +//! timestamps, counts and booleans as strings, so decoders parse at the point +//! of use. That holds for the catalog and admin surface this module serves. It +//! does NOT hold for native `SELECT`, which since NodeDB [Unreleased] returns +//! nested objects and arrays as structured values rather than JSON text; those +//! results carry richer shapes than `Table` can hold and belong in +//! `models::workbench::ResultSet`, not here. + +use crate::services::error::StudioError; + +/// A tabular result: column names plus rows of stringly values. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Table { + pub columns: Vec, + pub rows: Vec>, +} + +/// One row, addressable by column name. +#[allow(dead_code)] // SEAM-UNWIRED +pub struct Row<'a> { + columns: &'a [String], + cells: &'a [String], +} + +impl<'a> Row<'a> { + /// The cell under `name`, or an error naming the column that is missing. + #[allow(dead_code)] // SEAM-UNWIRED + pub fn field(&self, name: &str) -> Result<&'a str, StudioError> { + let idx = self.columns.iter().position(|c| c == name).ok_or_else(|| { + StudioError::UnexpectedColumns { + expected: name.to_string(), + got: self.columns.join(", "), + } + })?; + self.cells + .get(idx) + .map(String::as_str) + .ok_or_else(|| StudioError::UnexpectedColumns { + expected: format!("row to have at least {} cells", idx + 1), + got: format!("row has {} cells", self.cells.len()), + }) + } +} + +/// Decode every row of `table` with `f`, after asserting `expect` columns are +/// all present. +/// +/// The assertion is the point: it turns the server's silent session-variable +/// fallback into a typed error rather than an empty result set. +#[allow(dead_code)] // SEAM-UNWIRED +pub fn decode_rows( + table: &Table, + expect: &[&str], + f: impl Fn(Row<'_>) -> Result, +) -> Result, StudioError> { + if let Some(missing) = expect + .iter() + .find(|e| !table.columns.iter().any(|c| c == *e)) + { + return Err(StudioError::UnexpectedColumns { + expected: format!("{} (missing: {missing})", expect.join(", ")), + got: table.columns.join(", "), + }); + } + table + .rows + .iter() + .map(|cells| { + f(Row { + columns: &table.columns, + cells, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(cols: &[&str], rows: &[&[&str]]) -> Table { + Table { + columns: cols.iter().map(|s| s.to_string()).collect(), + rows: rows + .iter() + .map(|r| r.iter().map(|s| s.to_string()).collect()) + .collect(), + } + } + + #[test] + fn decodes_rows_by_column_name() { + let t = table(&["name", "owner"], &[&["probe_docs", "admin"]]); + let out = decode_rows(&t, &["name", "owner"], |r| { + Ok(format!("{}/{}", r.field("name")?, r.field("owner")?)) + }) + .expect("decode must succeed"); + assert_eq!(out, vec!["probe_docs/admin".to_string()]); + } + + #[test] + fn column_order_does_not_matter() { + let t = table(&["owner", "name"], &[&["admin", "probe_docs"]]); + let out = decode_rows(&t, &["name", "owner"], |r| Ok(r.field("name")?.to_string())) + .expect("decode must succeed"); + assert_eq!(out, vec!["probe_docs".to_string()]); + } + + /// The server answers some SHOW statements with a session-variable + /// fallback: cols=["setting"] and one empty row. That must be a typed + /// error, never an empty list, or the screen renders "working but empty". + #[test] + fn setting_fallback_is_an_error_not_empty() { + let t = table(&["setting"], &[&[""]]); + let out = decode_rows(&t, &["name", "collection"], |r| { + Ok(r.field("name")?.to_string()) + }); + assert!( + matches!(out, Err(StudioError::UnexpectedColumns { .. })), + "expected UnexpectedColumns, got {out:?}" + ); + } + + #[test] + fn genuinely_empty_result_is_ok_and_empty() { + let t = table(&["name", "collection"], &[]); + let out = decode_rows(&t, &["name", "collection"], |r| { + Ok(r.field("name")?.to_string()) + }) + .expect("empty rows with correct columns is a valid empty result"); + assert!(out.is_empty()); + } + + #[test] + fn missing_field_is_an_error() { + let t = table(&["name"], &[&["x"]]); + let out = decode_rows(&t, &["name"], |r| Ok(r.field("nope")?.to_string())); + assert!(out.is_err()); + } + + #[test] + fn short_row_is_an_error() { + let t = table(&["name", "owner", "id"], &[&["probe_docs", "admin"]]); + let out = decode_rows(&t, &["name", "owner", "id"], |r| { + Ok(r.field("id")?.to_string()) + }); + assert!( + matches!(out, Err(StudioError::UnexpectedColumns { .. })), + "expected UnexpectedColumns for short row, got {out:?}" + ); + } +} diff --git a/nodedb-studio/src/services/error.rs b/nodedb-studio/src/services/error.rs index a3e7c7a..4d246c6 100644 --- a/nodedb-studio/src/services/error.rs +++ b/nodedb-studio/src/services/error.rs @@ -30,6 +30,15 @@ pub enum StudioError { Server(#[source] NodeDbError), #[error("not connected to a database")] NotConnected, + /// A result set did not carry the columns the decoder needs. Most often the + /// server answered a `SHOW` with its session-variable fallback + /// (`cols=["setting"]`), which would otherwise read as an empty screen. + #[error("unexpected result columns: expected [{expected}], got [{got}]")] + #[allow(dead_code)] // SEAM-UNWIRED + UnexpectedColumns { expected: String, got: String }, + /// Connect was attempted without an explicit username. + #[error("a username is required to connect")] + MissingUsername, } impl StudioError { @@ -37,7 +46,9 @@ impl StudioError { /// `NotConnected` is never retriable (it is studio-originated, not transient). pub fn is_retriable(&self) -> bool { match self { - StudioError::NotConnected => false, + StudioError::NotConnected + | StudioError::UnexpectedColumns { .. } + | StudioError::MissingUsername => false, StudioError::Connection(e) | StudioError::Auth(e) | StudioError::NotFound(e) diff --git a/nodedb-studio/src/services/explorer_data.rs b/nodedb-studio/src/services/explorer_data.rs new file mode 100644 index 0000000..b311abc --- /dev/null +++ b/nodedb-studio/src/services/explorer_data.rs @@ -0,0 +1,132 @@ +//! Explorer-tier reads at the backend seam. +//! +//! `collection_groups` returns already-grouped collections because engine type +//! is not available in a single server call: the real implementation reads the +//! collection list, then resolves each collection's storage mode separately. +//! Keeping the grouping behind the seam means the sidebar never sees that. + +use async_trait::async_trait; + +use crate::models::explorer::{CollectionGroup, RecordDetail, RecordRow}; +use crate::services::error::StudioError; + +#[async_trait(?Send)] +pub trait ExplorerData { + /// Sidebar contents: collections grouped by storage mode, in display order. + async fn collection_groups(&self) -> Result, StudioError>; + + /// List-pane rows for one collection. + #[allow(dead_code)] // SEAM-UNWIRED + async fn records(&self, collection: &str) -> Result, StudioError>; + + /// Detail-panel contents for one record. + #[allow(dead_code)] // SEAM-UNWIRED + async fn record_detail(&self, collection: &str, id: &str) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::collection::StorageMode; + use crate::services::async_state::AsyncState; + use crate::services::connection_service::MockConnectionService; + + #[tokio::test] + async fn groups_are_ordered_and_non_empty() { + let svc = MockConnectionService::ready(); + let groups = svc.collection_groups().await.expect("ready yields groups"); + assert!(!groups.is_empty()); + for g in &groups { + assert!( + !g.collections.is_empty(), + "an empty group would render a header with no rows" + ); + } + // The sidebar renders groups in this exact sequence; it must track + // `StorageMode`'s own declared (canonical display) order. + let expected_order = [ + StorageMode::Document, + StorageMode::Strict, + StorageMode::Vector, + StorageMode::Graph, + StorageMode::Timeseries, + StorageMode::Kv, + StorageMode::Spatial, + StorageMode::Fts, + ]; + let modes: Vec = groups.iter().map(|g| g.mode).collect(); + assert_eq!( + modes, expected_order, + "groups must render in StorageMode's canonical display order" + ); + } + + #[tokio::test] + async fn every_collection_has_a_stable_unique_key() { + let svc = MockConnectionService::ready(); + let groups = svc.collection_groups().await.expect("ready yields groups"); + let mut names: Vec<&str> = groups + .iter() + .flat_map(|g| g.collections.iter().map(|c| c.name.as_str())) + .collect(); + let total = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(total, names.len(), "collection names must be unique keys"); + } + + #[tokio::test] + async fn records_have_unique_ids() { + let svc = MockConnectionService::ready(); + let rows = svc.records("users").await.expect("ready yields rows"); + let mut ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(total, ids.len()); + } + + #[tokio::test] + async fn empty_behaviour_reaches_the_empty_state() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.collection_groups().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn erroring_behaviour_reaches_the_error_state() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.collection_groups().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn record_detail_ready_returns_the_requested_id() { + let svc = MockConnectionService::ready(); + let detail = svc + .record_detail("users", "u1") + .await + .expect("ready yields a detail"); + assert_eq!(detail.id, "u1"); + } + + #[tokio::test] + async fn record_detail_erroring_is_err() { + let svc = MockConnectionService::erroring(); + assert!(svc.record_detail("users", "u1").await.is_err()); + } + + #[tokio::test] + async fn record_detail_empty_still_returns_the_requested_record() { + // `record_detail` reads a single record, not a list: "no rows" has no + // meaning here, so the mock folds `MockBehavior::Empty` into the same + // success path as `Ready` rather than inventing an absent/empty detail. + // Pinned here so a later refactor cannot silently change that meaning. + let svc = MockConnectionService::empty(); + let detail = svc + .record_detail("users", "u1") + .await + .expect("empty behaviour still returns a detail for a single-value read"); + assert_eq!(detail.id, "u1"); + } +} diff --git a/nodedb-studio/src/services/mock_behavior.rs b/nodedb-studio/src/services/mock_behavior.rs new file mode 100644 index 0000000..87301b9 --- /dev/null +++ b/nodedb-studio/src/services/mock_behavior.rs @@ -0,0 +1,183 @@ +//! Drives which result every mock seam method returns, so all four async +//! states are reachable on every screen rather than only on CDC. + +use std::time::Duration; + +use crate::services::error::StudioError; + +/// Which result the mock produces. `Delayed` exists so tests can observe the +/// Loading state and catch guards held across an await. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MockBehavior { + #[default] + Ready, + Empty, + Erroring, + Delayed(Duration), +} + +/// Apply the behaviour to a fixture thunk. Every mock seam method is a +/// one-liner over this, which is what keeps the four states uniform. +pub async fn apply( + behavior: MockBehavior, + ready: impl FnOnce() -> Vec, +) -> Result, StudioError> { + match behavior { + MockBehavior::Ready => Ok(ready()), + MockBehavior::Empty => Ok(Vec::new()), + MockBehavior::Erroring => Err(StudioError::from( + nodedb_client::NodeDbError::node_unreachable("mock"), + )), + MockBehavior::Delayed(d) => { + tokio::time::sleep(d).await; + Ok(ready()) + } + } +} + +/// Apply the behaviour to a single-value fixture thunk (used by seam methods +/// that read one value with no "empty" shape at all — `session_info`, +/// `nav_badges`, `record_detail`, `explain`). `Ready` and `Empty` both call +/// the thunk: a fetched single value here is never "empty", it either +/// arrived or it errored, so `Empty` folds into `Ready` rather than +/// inventing an absent value. +pub async fn apply_one( + behavior: MockBehavior, + ready: impl FnOnce() -> T, +) -> Result { + match behavior { + MockBehavior::Ready | MockBehavior::Empty => Ok(ready()), + MockBehavior::Erroring => Err(StudioError::from( + nodedb_client::NodeDbError::node_unreachable("mock"), + )), + MockBehavior::Delayed(d) => { + tokio::time::sleep(d).await; + Ok(ready()) + } + } +} + +/// Apply the behaviour to a single-value fixture thunk whose value can +/// itself be "empty" (used by `run_query` and `sub_graph`: a zero-row result +/// set or a zero-node graph is a real, common outcome, not an absent value). +/// Unlike `apply_one`, `Empty` calls `empty` rather than `ready`, so the +/// caller controls exactly what the empty shape looks like. +pub async fn apply_one_or_empty( + behavior: MockBehavior, + ready: impl FnOnce() -> T, + empty: impl FnOnce() -> T, +) -> Result { + match behavior { + MockBehavior::Ready => Ok(ready()), + MockBehavior::Empty => Ok(empty()), + MockBehavior::Erroring => Err(StudioError::from( + nodedb_client::NodeDbError::node_unreachable("mock"), + )), + MockBehavior::Delayed(d) => { + tokio::time::sleep(d).await; + Ok(ready()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn ready_returns_the_fixture() { + let out = apply(MockBehavior::Ready, || vec![1u8, 2]).await; + assert_eq!(out.expect("ready yields data"), vec![1u8, 2]); + } + + #[tokio::test] + async fn empty_returns_no_rows() { + let out = apply(MockBehavior::Empty, || vec![1u8, 2]).await; + assert!(out.expect("empty yields Ok").is_empty()); + } + + #[tokio::test] + async fn erroring_returns_a_retriable_error() { + let out = apply(MockBehavior::Erroring, || vec![1u8]).await; + let err = out.expect_err("erroring yields Err"); + assert!( + err.is_retriable(), + "demo error must exercise the retry path" + ); + } + + #[tokio::test] + async fn delayed_still_returns_the_fixture() { + let out = apply(MockBehavior::Delayed(Duration::from_millis(5)), || { + vec![9u8] + }) + .await; + assert_eq!(out.expect("delayed yields data"), vec![9u8]); + } + + #[tokio::test] + async fn apply_one_ready_returns_the_fixture() { + let out = apply_one(MockBehavior::Ready, || 7u8).await; + assert_eq!(out.expect("ready yields data"), 7u8); + } + + #[tokio::test] + async fn apply_one_empty_still_returns_the_fixture() { + // Unlike `apply`, a single value has no "empty" shape: `Empty` folds + // into `Ready` rather than inventing an absent value. + let out = apply_one(MockBehavior::Empty, || 7u8).await; + assert_eq!(out.expect("empty folds into ready"), 7u8); + } + + #[tokio::test] + async fn apply_one_erroring_returns_a_retriable_error() { + let out = apply_one(MockBehavior::Erroring, || 7u8).await; + let err = out.expect_err("erroring yields Err"); + assert!( + err.is_retriable(), + "demo error must exercise the retry path" + ); + } + + #[tokio::test] + async fn apply_one_delayed_still_returns_the_fixture() { + let out = apply_one(MockBehavior::Delayed(Duration::from_millis(5)), || 9u8).await; + assert_eq!(out.expect("delayed yields data"), 9u8); + } + + #[tokio::test] + async fn apply_one_or_empty_ready_returns_the_fixture() { + let out = apply_one_or_empty(MockBehavior::Ready, || 7u8, || 0u8).await; + assert_eq!(out.expect("ready yields data"), 7u8); + } + + #[tokio::test] + async fn apply_one_or_empty_empty_returns_the_empty_thunk() { + // Unlike `apply_one`, `Empty` does NOT fold into `Ready` here: the + // caller's `empty` thunk runs instead, so a genuinely empty payload + // is reachable for single-value reads that have a real empty shape. + let out = apply_one_or_empty(MockBehavior::Empty, || 7u8, || 0u8).await; + assert_eq!(out.expect("empty yields the empty thunk"), 0u8); + } + + #[tokio::test] + async fn apply_one_or_empty_erroring_returns_a_retriable_error() { + let out = apply_one_or_empty(MockBehavior::Erroring, || 7u8, || 0u8).await; + let err = out.expect_err("erroring yields Err"); + assert!( + err.is_retriable(), + "demo error must exercise the retry path" + ); + } + + #[tokio::test] + async fn apply_one_or_empty_delayed_still_returns_the_fixture() { + let out = apply_one_or_empty( + MockBehavior::Delayed(Duration::from_millis(5)), + || 9u8, + || 0u8, + ) + .await; + assert_eq!(out.expect("delayed yields data"), 9u8); + } +} diff --git a/nodedb-studio/src/services/mod.rs b/nodedb-studio/src/services/mod.rs index 3a007ca..765c6fe 100644 --- a/nodedb-studio/src/services/mod.rs +++ b/nodedb-studio/src/services/mod.rs @@ -1,9 +1,15 @@ //! Service traits at the backend seam. The mock impl is the only one today; //! a NodeDB-client-backed impl plugs in here later. +pub mod admin_data; pub mod async_state; pub mod backend; pub mod connection_service; +pub mod decode; pub mod error; +pub mod explorer_data; +pub mod mock_behavior; pub mod nodedb_service; pub mod streams_data; +pub mod viewers_data; +pub mod workbench_data; diff --git a/nodedb-studio/src/services/nodedb_service.rs b/nodedb-studio/src/services/nodedb_service.rs index abfff74..60638f3 100644 --- a/nodedb-studio/src/services/nodedb_service.rs +++ b/nodedb-studio/src/services/nodedb_service.rs @@ -10,13 +10,27 @@ use async_trait::async_trait; +use crate::models::admin::{AuditEntry, ClusterNode, RaftGroup, RlsPolicy, ShardRange, UserRow}; use crate::models::cdc::CdcRow; +use crate::models::explorer::{CollectionGroup, RecordDetail, RecordRow}; use crate::models::notification::Notification; +use crate::models::shell::{NavBadges, SessionInfo}; +use crate::models::streams::{ + MaterializedView, NotifyChannel, NotifyMessage, ScheduledJob, StreamSession, Topic, +}; +use crate::models::viewers::{ + FtsHit, SeriesPoint, SpatialFeature, SubGraph, SyncPeer, VectorPoint, +}; +use crate::models::workbench::{QueryPlan, ResultSet, SchemaNode}; +use crate::services::admin_data::AdminData; use crate::services::connection_service::ConnectionService; use crate::services::error::StudioError; +use crate::services::explorer_data::ExplorerData; use crate::services::streams_data::StreamsData; +use crate::services::viewers_data::ViewersData; +use crate::services::workbench_data::WorkbenchData; use crate::state::connection::ActiveConnection; -use crate::state::connections_registry::SavedConnection; +use crate::state::connections_registry::{Credentials, SavedConnection}; // The Phase-2 seam impl: its trait conformance and object-safety are proven by // the tests below, but the mock is still the active injected service, so this @@ -36,13 +50,29 @@ impl ConnectionService for NodeDbConnectionService { Err(StudioError::NotConnected) } - async fn connect(&self, _name: &str) -> Result { + async fn connect( + &self, + _name: &str, + _creds: &Credentials, + ) -> Result { Err(StudioError::NotConnected) } async fn mark_all_read(&self) -> Result<(), StudioError> { Err(StudioError::NotConnected) } + + async fn nav_badges(&self) -> Result { + Err(StudioError::NotConnected) + } + + async fn session_info(&self) -> Result { + Err(StudioError::NotConnected) + } + + async fn databases(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } } #[async_trait(?Send)] @@ -50,6 +80,135 @@ impl StreamsData for NodeDbConnectionService { async fn cdc_feed(&self) -> Result, StudioError> { Err(StudioError::NotConnected) } + + async fn open_stream_session(&self, _stream: &str) -> Result { + Err(StudioError::NotConnected) + } + + async fn cdc_batch( + &self, + _session: &StreamSession, + _limit: usize, + ) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn commit_stream_offsets(&self, _session: &StreamSession) -> Result<(), StudioError> { + Err(StudioError::NotConnected) + } + + async fn close_stream_session(&self, _session: &StreamSession) -> Result<(), StudioError> { + Err(StudioError::NotConnected) + } + + async fn materialized_views(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn topics(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn scheduled_jobs(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn notify_channels(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn notify_messages(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } +} + +#[async_trait(?Send)] +impl ExplorerData for NodeDbConnectionService { + async fn collection_groups(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + async fn records(&self, _collection: &str) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + async fn record_detail( + &self, + _collection: &str, + _id: &str, + ) -> Result { + Err(StudioError::NotConnected) + } +} + +#[async_trait(?Send)] +impl AdminData for NodeDbConnectionService { + async fn cluster_nodes(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn raft_groups(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn shard_ranges(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn users(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn rls_policies(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn audit_entries(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } +} + +#[async_trait(?Send)] +impl WorkbenchData for NodeDbConnectionService { + async fn run_query(&self, _sql: &str) -> Result { + Err(StudioError::NotConnected) + } + + async fn explain(&self, _sql: &str) -> Result { + Err(StudioError::NotConnected) + } + + async fn schema_tree(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } +} + +#[async_trait(?Send)] +impl ViewersData for NodeDbConnectionService { + async fn sub_graph(&self, _collection: &str) -> Result { + Err(StudioError::NotConnected) + } + + async fn vector_points(&self, _collection: &str) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn series(&self, _metric: &str) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn spatial_features( + &self, + _collection: &str, + ) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn fts_hits(&self, _collection: &str, _query: &str) -> Result, StudioError> { + Err(StudioError::NotConnected) + } + + async fn sync_peers(&self) -> Result, StudioError> { + Err(StudioError::NotConnected) + } } #[cfg(test)] @@ -69,8 +228,12 @@ mod tests { svc.notifications().await, Err(StudioError::NotConnected) )); + let creds = Credentials { + username: "alice".into(), + password: None, + }; assert!(matches!( - svc.connect("anything").await, + svc.connect("anything", &creds).await, Err(StudioError::NotConnected) )); assert!(matches!( @@ -79,6 +242,111 @@ mod tests { )); } + #[tokio::test] + async fn stub_shell_chrome_reads_are_not_connected() { + let svc = NodeDbConnectionService; + assert!(matches!( + svc.nav_badges().await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.session_info().await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.databases().await, + Err(StudioError::NotConnected) + )); + } + + #[tokio::test] + async fn stub_streams_lifecycle_and_lists_are_not_connected() { + let svc = NodeDbConnectionService; + let session = StreamSession { + stream: "cdc".into(), + group: "studio_cdc".into(), + }; + assert!(matches!( + svc.open_stream_session("cdc").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.cdc_batch(&session, 10).await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.commit_stream_offsets(&session).await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.close_stream_session(&session).await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.materialized_views().await, + Err(StudioError::NotConnected) + )); + assert!(matches!(svc.topics().await, Err(StudioError::NotConnected))); + assert!(matches!( + svc.scheduled_jobs().await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.notify_channels().await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.notify_messages().await, + Err(StudioError::NotConnected) + )); + } + + #[tokio::test] + async fn stub_workbench_reads_are_not_connected() { + let svc = NodeDbConnectionService; + assert!(matches!( + svc.run_query("SELECT 1").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.explain("SELECT 1").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.schema_tree().await, + Err(StudioError::NotConnected) + )); + } + + #[tokio::test] + async fn stub_viewer_reads_are_not_connected() { + let svc = NodeDbConnectionService; + assert!(matches!( + svc.sub_graph("social").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.vector_points("embeddings").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.series("qps").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.spatial_features("places").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.fts_hits("articles", "nodedb").await, + Err(StudioError::NotConnected) + )); + assert!(matches!( + svc.sync_peers().await, + Err(StudioError::NotConnected) + )); + } + #[test] fn stub_is_object_safe_behind_rc() { // Compile-time guarantee: the stub coerces to the seam trait object, diff --git a/nodedb-studio/src/services/streams_data.rs b/nodedb-studio/src/services/streams_data.rs index 49692a1..40b2f47 100644 --- a/nodedb-studio/src/services/streams_data.rs +++ b/nodedb-studio/src/services/streams_data.rs @@ -1,15 +1,65 @@ -//! Streams-tier reads at the backend seam. One method per Streams screen's data. -//! Today: CDC. Notify/MV/Topics/Cron methods are added when those screens are built. +//! Streams-tier reads at the backend seam. One method per Streams screen's data, +//! plus the CDC consumer-group lifecycle (open/read/commit/close). +//! +//! CDC reads are idempotent by design: re-reading without committing returns +//! the same rows, and only an explicit commit advances the cursor. A poll loop +//! that never commits re-reads the same window forever; one that commits on a +//! *shared* consumer group advances a production consumer past events it never +//! processed. Studio therefore always opens its own group (`studio_`), +//! commits its own batches, and drops the group on disconnect. use async_trait::async_trait; use crate::models::cdc::{CdcOp, CdcRow}; +use crate::models::streams::{ + MaterializedView, NotifyChannel, NotifyMessage, ScheduledJob, StreamSession, Topic, +}; use crate::services::error::StudioError; #[async_trait(?Send)] pub trait StreamsData { /// The CDC change feed, newest first. async fn cdc_feed(&self) -> Result, StudioError>; + + /// Create a Studio-owned consumer group on `stream` and return the session. + /// Callers must pair this with `close_stream_session`. + #[allow(dead_code)] // SEAM-UNWIRED + async fn open_stream_session(&self, stream: &str) -> Result; + + /// Read up to `limit` events from the session's current cursor. Idempotent: + /// re-reading without committing returns the same events. + #[allow(dead_code)] // SEAM-UNWIRED + async fn cdc_batch( + &self, + session: &StreamSession, + limit: usize, + ) -> Result, StudioError>; + + /// Advance the session's cursor past everything read so far. + #[allow(dead_code)] // SEAM-UNWIRED + async fn commit_stream_offsets(&self, session: &StreamSession) -> Result<(), StudioError>; + + /// Drop the Studio-owned consumer group. + #[allow(dead_code)] // SEAM-UNWIRED + async fn close_stream_session(&self, session: &StreamSession) -> Result<(), StudioError>; + + /// Materialized views known to the cluster. + #[allow(dead_code)] // SEAM-UNWIRED + async fn materialized_views(&self) -> Result, StudioError>; + + /// Durable, replayable topics. + #[allow(dead_code)] // SEAM-UNWIRED + async fn topics(&self) -> Result, StudioError>; + + /// Cron-style scheduled jobs. + #[allow(dead_code)] // SEAM-UNWIRED + async fn scheduled_jobs(&self) -> Result, StudioError>; + + /// LISTEN/NOTIFY channels. + async fn notify_channels(&self) -> Result, StudioError>; + + /// The pub/sub message tail across channels. + async fn notify_messages(&self) -> Result, StudioError>; } /// Build display rows from the static mock change feed. Lives here (not in the @@ -44,6 +94,8 @@ pub(crate) fn cdc_rows_from_mock() -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::services::async_state::AsyncState; + use crate::services::connection_service::MockConnectionService; #[test] fn mock_rows_have_unique_stable_ids() { @@ -56,4 +108,294 @@ mod tests { assert_eq!(ids.len(), count, "ids must be unique"); assert_eq!(rows[0].id, "cdc-0"); } + + // Each method gets its own empty/erroring pair rather than one combined + // check per behaviour, mirroring `admin_data.rs`: `apply(self.behavior, mock::x)` + // and a mis-wired `Ok(mock::x())` both satisfy a single shared assertion, so + // every method needs its own proof that it actually reads `self.behavior`. + + #[tokio::test] + async fn every_streams_list_read_has_unique_ids() { + let svc = MockConnectionService::ready(); + let mvs = svc.materialized_views().await.expect("mvs"); + let topics = svc.topics().await.expect("topics"); + let jobs = svc.scheduled_jobs().await.expect("jobs"); + let channels = svc.notify_channels().await.expect("channels"); + let messages = svc.notify_messages().await.expect("messages"); + + assert_unique(mvs.iter().map(|x| x.id.as_str()), "materialized_views"); + assert_unique(topics.iter().map(|x| x.id.as_str()), "topics"); + assert_unique(jobs.iter().map(|x| x.id.as_str()), "scheduled_jobs"); + assert_unique(channels.iter().map(|x| x.id.as_str()), "notify_channels"); + assert_unique(messages.iter().map(|x| x.id.as_str()), "notify_messages"); + } + + fn assert_unique<'a>(it: impl Iterator, what: &str) { + let mut v: Vec<&str> = it.collect(); + let total = v.len(); + assert!(total > 0, "{what} fixture must not be empty"); + v.sort_unstable(); + v.dedup(); + assert_eq!(total, v.len(), "{what} ids must be unique"); + } + + #[tokio::test] + async fn materialized_views_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.materialized_views().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn materialized_views_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.materialized_views().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn topics_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.topics().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn topics_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.topics().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn scheduled_jobs_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.scheduled_jobs().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn scheduled_jobs_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.scheduled_jobs().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn notify_channels_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.notify_channels().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn notify_channels_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.notify_channels().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn notify_messages_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.notify_messages().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn notify_messages_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.notify_messages().await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn scheduled_jobs_fixture_has_a_failed_and_a_successful_job() { + let svc = MockConnectionService::ready(); + let jobs = svc.scheduled_jobs().await.expect("jobs"); + assert!( + jobs.iter().any(|j| j.last_status == "success"), + "fixture must include a successful job" + ); + assert!( + jobs.iter().any(|j| j.last_status.starts_with("failed")), + "fixture must include a failed job so the status column isn't uniform" + ); + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use crate::services::connection_service::MockConnectionService; + use crate::services::mock_behavior::MockBehavior; + + #[tokio::test] + async fn session_group_is_studio_scoped() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session opens"); + assert!( + s.group.starts_with("studio_"), + "Studio must use its own consumer group, got {}", + s.group + ); + } + + #[tokio::test] + async fn open_stream_session_preserves_the_requested_stream_name() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session opens"); + assert_eq!(s.stream, "cdc"); + assert_eq!(s.group, "studio_cdc"); + } + + /// Reads do not advance the cursor: two reads without a commit return the + /// same rows. A naive poll loop would therefore repeat forever. + #[tokio::test] + async fn reads_are_idempotent_until_committed() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session"); + let first = svc.cdc_batch(&s, 10).await.expect("first read"); + let second = svc.cdc_batch(&s, 10).await.expect("second read"); + assert_eq!(first, second, "an uncommitted re-read must be identical"); + assert!(!first.is_empty()); + } + + #[tokio::test] + async fn commit_advances_past_the_batch() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session"); + let first = svc.cdc_batch(&s, 10).await.expect("first read"); + svc.commit_stream_offsets(&s).await.expect("commit"); + let after = svc.cdc_batch(&s, 10).await.expect("read after commit"); + assert!( + after.len() < first.len() || after.is_empty(), + "commit must advance the cursor" + ); + } + + #[tokio::test] + async fn limit_is_respected() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session"); + assert!(svc.cdc_batch(&s, 2).await.expect("read").len() <= 2); + } + + /// `close_stream_session` drops the group by resetting the cursor, so a + /// freshly opened session sees the same window a brand-new one would. + #[tokio::test] + async fn close_stream_session_resets_the_cursor_for_the_next_session() { + let svc = MockConnectionService::ready(); + let s = svc.open_stream_session("cdc").await.expect("session"); + let first = svc.cdc_batch(&s, 10).await.expect("first read"); + svc.commit_stream_offsets(&s).await.expect("commit"); + svc.close_stream_session(&s).await.expect("close"); + + let s2 = svc + .open_stream_session("cdc") + .await + .expect("reopened session"); + let after_reopen = svc.cdc_batch(&s2, 10).await.expect("read after reopen"); + assert_eq!( + first, after_reopen, + "closing must drop the committed offset, not carry it forward" + ); + } + + /// `commit_stream_offsets` and `close_stream_session` are not gated by the + /// mock's behaviour switch and always succeed, even when reads fail. This is + /// unlike `mark_all_read`, which is gated so its failure path is testable; + /// these two have no UI call site yet, so nothing depends on them failing. + #[tokio::test] + async fn commit_and_close_succeed_even_when_reads_error() { + let svc = MockConnectionService::erroring(); + let s = svc + .open_stream_session("cdc") + .await + .expect("open is not a read"); + assert!(svc.cdc_batch(&s, 10).await.is_err(), "reads still fail"); + assert!(svc.commit_stream_offsets(&s).await.is_ok()); + assert!(svc.close_stream_session(&s).await.is_ok()); + } + + #[tokio::test] + async fn cdc_batch_empty_behavior_returns_no_rows() { + let svc = MockConnectionService::empty(); + let s = svc.open_stream_session("cdc").await.expect("session"); + let batch = svc.cdc_batch(&s, 10).await.expect("empty read is Ok"); + assert!(batch.is_empty()); + } + + #[tokio::test] + async fn cdc_batch_erroring_behavior_is_err() { + let svc = MockConnectionService::erroring(); + let s = svc.open_stream_session("cdc").await.expect("session"); + assert!(svc.cdc_batch(&s, 10).await.is_err()); + } + + /// Regression: `cdc_batch` used to record `cdc_read_end` from the + /// computed batch *before* consulting `self.behavior`, so an erroring + /// read still moved the cursor as if it had delivered every row. A + /// following commit then promoted that phantom offset into + /// `cdc_committed`, silently skipping events the caller never saw. This + /// simulates the same session's connection recovering (shared cursor + /// cells, `Erroring` swapped for `Ready`) and proves the events are + /// still there to read. + #[tokio::test] + async fn erroring_read_then_commit_does_not_skip_events_once_reads_recover() { + let erroring = MockConnectionService::erroring(); + let s = erroring + .open_stream_session("cdc") + .await + .expect("open is not a read"); + assert!( + erroring.cdc_batch(&s, 10).await.is_err(), + "read fails as configured" + ); + erroring + .commit_stream_offsets(&s) + .await + .expect("commit always succeeds, even after a failed read"); + + let recovered = erroring.with_shared_state(MockBehavior::Ready); + let after = recovered + .cdc_batch(&s, 10) + .await + .expect("the recovered read succeeds"); + assert!( + !after.is_empty(), + "a commit after a failed read must not have advanced the cursor \ + past events the caller never saw" + ); + } + + /// Same regression as above, for the `Empty` behaviour: an empty read + /// delivers zero rows, so a following commit must leave the cursor + /// exactly where it was, not wherever the (discarded) full batch would + /// have ended. + #[tokio::test] + async fn empty_read_then_commit_does_not_skip_events_once_reads_recover() { + let empty = MockConnectionService::empty(); + let s = empty + .open_stream_session("cdc") + .await + .expect("open is not a read"); + let first = empty.cdc_batch(&s, 10).await.expect("empty read is Ok"); + assert!(first.is_empty(), "Empty behaviour delivers no rows"); + empty + .commit_stream_offsets(&s) + .await + .expect("commit always succeeds, even after an empty read"); + + let recovered = empty.with_shared_state(MockBehavior::Ready); + let after = recovered + .cdc_batch(&s, 10) + .await + .expect("the recovered read succeeds"); + assert!( + !after.is_empty(), + "a commit after an empty read must not have advanced the cursor \ + past events the caller never saw" + ); + } } diff --git a/nodedb-studio/src/services/viewers_data.rs b/nodedb-studio/src/services/viewers_data.rs new file mode 100644 index 0000000..1fa6035 --- /dev/null +++ b/nodedb-studio/src/services/viewers_data.rs @@ -0,0 +1,333 @@ +//! Specialized-viewer reads at the backend seam: graph, vector, timeseries, +//! spatial, FTS, and sync. +//! +//! `sub_graph` returns a single `SubGraph` rather than a list, so it cannot +//! be expressed as `apply(self.behavior, ...)`, which only knows how to fold +//! `MockBehavior::Empty` into `Vec::new()`. Its mock implementation instead +//! uses `apply_one_or_empty`, which lets a single-value read that wraps a +//! list (a result set's rows, a graph's nodes) decide what a genuinely empty +//! payload looks like, rather than folding `Empty` into `Ready` the way +//! `record_detail` does. +//! +//! `sub_graph`, `vector_points`, `spatial_features` and `fts_hits` all take a +//! `collection`: the Explorer already scopes its selection to one collection +//! per storage mode (graph/vector/spatial), so each viewer's read must be +//! parameterised the same way `records(collection)` already is. `sync_peers` +//! is deliberately left unparameterised — it is instance-scoped, not +//! per-collection. + +use async_trait::async_trait; + +use crate::models::viewers::{ + FtsHit, SeriesPoint, SpatialFeature, SubGraph, SyncPeer, VectorPoint, +}; +use crate::services::error::StudioError; + +#[async_trait(?Send)] +pub trait ViewersData { + /// One graph viewer's full render input (nodes + edges) for `collection`. + #[allow(dead_code)] // SEAM-UNWIRED + async fn sub_graph(&self, collection: &str) -> Result; + + /// A 2D projection of vector embeddings in `collection` for the vector + /// viewer. + #[allow(dead_code)] // SEAM-UNWIRED + async fn vector_points(&self, collection: &str) -> Result, StudioError>; + + /// Samples for one timeseries metric. + #[allow(dead_code)] // SEAM-UNWIRED + async fn series(&self, metric: &str) -> Result, StudioError>; + + /// Features for the spatial viewer's map, scoped to `collection`. + #[allow(dead_code)] // SEAM-UNWIRED + async fn spatial_features(&self, collection: &str) -> Result, StudioError>; + + /// Full-text-search hits for `query` within `collection`. + #[allow(dead_code)] // SEAM-UNWIRED + async fn fts_hits(&self, collection: &str, query: &str) -> Result, StudioError>; + + /// Sync/replication peers. + #[allow(dead_code)] // SEAM-UNWIRED + async fn sync_peers(&self) -> Result, StudioError>; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::async_state::AsyncState; + use crate::services::connection_service::MockConnectionService; + + #[tokio::test] + async fn subgraph_edges_reference_existing_nodes() { + let svc = MockConnectionService::ready(); + let g = svc.sub_graph("social").await.expect("graph"); + let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect(); + assert!(!g.nodes.is_empty() && !g.edges.is_empty()); + for e in &g.edges { + assert!( + ids.contains(&e.from.as_str()), + "dangling edge from {}", + e.from + ); + assert!(ids.contains(&e.to.as_str()), "dangling edge to {}", e.to); + } + } + + #[tokio::test] + async fn every_viewer_read_is_keyed_and_non_empty() { + let svc = MockConnectionService::ready(); + + let vector = svc.vector_points("embeddings").await.expect("vec"); + assert!(!vector.is_empty()); + assert!( + vector.iter().all(|p| !p.id.is_empty()), + "every vector point needs a stable key" + ); + + let series = svc.series("qps").await.expect("series"); + assert!(!series.is_empty()); + assert!( + series.iter().all(|p| !p.id.is_empty()), + "every series point needs a stable key" + ); + + let spatial = svc.spatial_features("places").await.expect("geo"); + assert!(!spatial.is_empty()); + assert!( + spatial.iter().all(|f| !f.id.is_empty()), + "every spatial feature needs a stable key" + ); + + let fts = svc.fts_hits("articles", "nodedb").await.expect("fts"); + assert!(!fts.is_empty()); + assert!( + fts.iter().all(|h| !h.id.is_empty()), + "every fts hit needs a stable key" + ); + + let peers = svc.sync_peers().await.expect("peers"); + assert!(!peers.is_empty()); + assert!( + peers.iter().all(|p| !p.id.is_empty()), + "every sync peer needs a stable key" + ); + } + + // Each method below gets its own named ready/empty/erroring coverage: + // `apply(self.behavior, mock::x)` and a mis-wired `Ok(mock::x())` both + // satisfy a single shared assertion, so every method needs its own proof + // that it actually reads `self.behavior` (see admin_data.rs). + + #[tokio::test] + async fn sub_graph_ready_returns_nodes_and_edges() { + let svc = MockConnectionService::ready(); + let g = svc.sub_graph("social").await.expect("ready yields a graph"); + assert!(!g.nodes.is_empty(), "fixture must have nodes"); + assert!(!g.edges.is_empty(), "fixture must have edges"); + } + + #[tokio::test] + async fn sub_graph_ids_vary_by_collection() { + // A wrong-argument bug (e.g. ignoring `collection`) must be visible: + // the fixture keys every id off the requested collection. + let svc = MockConnectionService::ready(); + let a = svc.sub_graph("social").await.expect("graph"); + let b = svc.sub_graph("orders").await.expect("graph"); + assert_ne!( + a.nodes.first().map(|n| n.id.as_str()), + b.nodes.first().map(|n| n.id.as_str()), + "node ids must key off the requested collection" + ); + } + + #[tokio::test] + async fn sub_graph_empty_behaviour_returns_zero_nodes() { + // Unlike the truly single-value reads (`record_detail`), a graph's + // emptiness is meaningful: a collection with no nodes is a real + // outcome for the graph viewer. `MockBehavior::Empty` must therefore + // deliver a genuinely empty graph, not fold into `Ready`. + let svc = MockConnectionService::empty(); + let g = svc + .sub_graph("social") + .await + .expect("empty behaviour is still Ok"); + assert!(g.nodes.is_empty(), "empty behaviour must yield zero nodes"); + assert!(g.edges.is_empty(), "empty behaviour must yield zero edges"); + } + + #[tokio::test] + async fn sub_graph_empty_behaviour_reaches_the_empty_state() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.sub_graph("social").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn sub_graph_erroring_is_err() { + let svc = MockConnectionService::erroring(); + assert!(svc.sub_graph("social").await.is_err()); + } + + #[tokio::test] + async fn vector_points_ready_has_two_clusters() { + // Expectation authored independently of the fixture body: the fixture + // alternates clusters "a"/"b", so both must be present. + let svc = MockConnectionService::ready(); + let pts = svc + .vector_points("embeddings") + .await + .expect("ready yields points"); + assert!(pts.iter().any(|p| p.cluster == "a")); + assert!(pts.iter().any(|p| p.cluster == "b")); + } + + #[tokio::test] + async fn vector_points_ids_vary_by_collection() { + let svc = MockConnectionService::ready(); + let a = svc.vector_points("embeddings").await.expect("vec"); + let b = svc.vector_points("orders").await.expect("vec"); + assert_ne!( + a.first().map(|p| p.id.as_str()), + b.first().map(|p| p.id.as_str()), + "vector point ids must key off the requested collection" + ); + } + + #[tokio::test] + async fn vector_points_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.vector_points("embeddings").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn vector_points_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.vector_points("embeddings").await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn series_ready_ids_are_prefixed_with_the_requested_metric() { + let svc = MockConnectionService::ready(); + let points = svc.series("qps").await.expect("ready yields points"); + assert!( + points.iter().all(|p| p.id.starts_with("qps-")), + "series ids must key off the requested metric, not a fixed name" + ); + } + + #[tokio::test] + async fn series_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.series("qps").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn series_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.series("qps").await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn spatial_features_ready_have_geometry() { + let svc = MockConnectionService::ready(); + let feats = svc + .spatial_features("places") + .await + .expect("ready yields features"); + assert!( + feats.iter().all(|f| !f.geometry_json.is_empty()), + "every feature must carry display geometry" + ); + } + + #[tokio::test] + async fn spatial_features_ids_vary_by_collection() { + let svc = MockConnectionService::ready(); + let a = svc.spatial_features("places").await.expect("geo"); + let b = svc.spatial_features("orders").await.expect("geo"); + assert_ne!( + a.first().map(|f| f.id.as_str()), + b.first().map(|f| f.id.as_str()), + "spatial feature ids must key off the requested collection" + ); + } + + #[tokio::test] + async fn spatial_features_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.spatial_features("places").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn spatial_features_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.spatial_features("places").await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn fts_hits_ready_excerpts_mention_the_query() { + let svc = MockConnectionService::ready(); + let hits = svc + .fts_hits("articles", "nodedb") + .await + .expect("ready yields hits"); + assert!( + hits.iter().all(|h| h.excerpt.contains("nodedb")), + "excerpts must reflect the requested query, not a fixed string" + ); + } + + #[tokio::test] + async fn fts_hits_ids_vary_by_collection() { + let svc = MockConnectionService::ready(); + let a = svc.fts_hits("articles", "nodedb").await.expect("fts"); + let b = svc.fts_hits("orders", "nodedb").await.expect("fts"); + assert_ne!( + a.first().map(|h| h.id.as_str()), + b.first().map(|h| h.id.as_str()), + "fts hit ids must key off the requested collection" + ); + } + + #[tokio::test] + async fn fts_hits_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.fts_hits("articles", "nodedb").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn fts_hits_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.fts_hits("articles", "nodedb").await)); + assert!(s.error_message().is_some()); + } + + #[tokio::test] + async fn sync_peers_ready_has_a_lagging_and_a_synced_peer() { + let svc = MockConnectionService::ready(); + let peers = svc.sync_peers().await.expect("ready yields peers"); + assert!(peers.iter().any(|p| p.state == "synced")); + assert!(peers.iter().any(|p| p.state == "lagging")); + } + + #[tokio::test] + async fn sync_peers_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.sync_peers().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn sync_peers_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.sync_peers().await)); + assert!(s.error_message().is_some()); + } +} diff --git a/nodedb-studio/src/services/workbench_data.rs b/nodedb-studio/src/services/workbench_data.rs new file mode 100644 index 0000000..aa7303b --- /dev/null +++ b/nodedb-studio/src/services/workbench_data.rs @@ -0,0 +1,180 @@ +//! Workbench-tier reads at the backend seam: query execution, EXPLAIN, and the +//! schema tree. +//! +//! `run_query` returns a `ResultSet` rather than a raw table because +//! pagination is the seam's job: the real client buffers whole result sets +//! with no cursor, so the eventual implementation emits LIMIT/OFFSET here +//! rather than holding one. + +use async_trait::async_trait; + +use crate::models::workbench::{QueryPlan, ResultSet, SchemaNode}; +use crate::services::error::StudioError; + +#[async_trait(?Send)] +pub trait WorkbenchData { + /// Execute `sql` and return one page of results. + #[allow(dead_code)] // SEAM-UNWIRED + async fn run_query(&self, sql: &str) -> Result; + + /// The query planner's EXPLAIN output for `sql`. + #[allow(dead_code)] // SEAM-UNWIRED + async fn explain(&self, sql: &str) -> Result; + + /// The schema tree for the connected database. + #[allow(dead_code)] // SEAM-UNWIRED + async fn schema_tree(&self) -> Result, StudioError>; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::async_state::AsyncState; + use crate::services::connection_service::MockConnectionService; + + #[tokio::test] + async fn run_query_returns_columns_and_keyed_rows() { + let svc = MockConnectionService::ready(); + let rs = svc.run_query("SELECT 1").await.expect("query runs"); + assert!(!rs.columns.is_empty()); + for r in &rs.rows { + assert_eq!( + r.cells.len(), + rs.columns.len(), + "row width must match header" + ); + assert!(!r.id.is_empty(), "rows need a stable key"); + } + } + + #[tokio::test] + async fn run_query_ready_fixture_has_three_columns_and_four_rows() { + // Expectation authored independently of the fixture body: the brief + // pins the shape at "3-column, 4-row", so this asserts those literal + // numbers rather than deriving them from the call under test. + let svc = MockConnectionService::ready(); + let rs = svc.run_query("SELECT 1").await.expect("query runs"); + assert_eq!(rs.columns.len(), 3, "fixture is documented as 3 columns"); + assert_eq!(rs.rows.len(), 4, "fixture is documented as 4 rows"); + } + + #[tokio::test] + async fn run_query_empty_behaviour_returns_zero_rows() { + // Unlike the truly single-value reads (`record_detail`, `explain`), + // a result set's emptiness is meaningful: "no rows" is the most + // common non-error outcome for a query. `MockBehavior::Empty` must + // therefore deliver a genuinely empty result set, not fold into + // `Ready`. + let svc = MockConnectionService::empty(); + let rs = svc + .run_query("SELECT 1") + .await + .expect("empty behaviour is still Ok"); + assert!(rs.rows.is_empty(), "empty behaviour must yield zero rows"); + } + + #[tokio::test] + async fn run_query_empty_behaviour_reaches_the_empty_state() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.run_query("SELECT 1").await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn erroring_surfaces_through_run_query() { + let svc = MockConnectionService::erroring(); + assert!(svc.run_query("SELECT 1").await.is_err()); + } + + #[tokio::test] + async fn explain_returns_a_plan() { + let svc = MockConnectionService::ready(); + let p = svc.explain("SELECT 1").await.expect("explain runs"); + assert!(!p.text.is_empty()); + } + + #[tokio::test] + async fn explain_ready_fixture_mentions_a_scan() { + // Independently-authored expectation: the fixture is documented as a + // deterministic plan string, so this checks for a marker the fixture + // is known to contain rather than re-deriving it from the same call. + let svc = MockConnectionService::ready(); + let p = svc.explain("SELECT 1").await.expect("explain runs"); + assert!( + p.text.contains("Scan"), + "fixture plan text must describe a scan" + ); + } + + #[tokio::test] + async fn explain_empty_behaviour_still_returns_a_plan() { + let svc = MockConnectionService::empty(); + let p = svc + .explain("SELECT 1") + .await + .expect("empty behaviour still returns a plan for a single-value read"); + assert!(!p.text.is_empty()); + } + + #[tokio::test] + async fn explain_erroring_is_err() { + let svc = MockConnectionService::erroring(); + assert!(svc.explain("SELECT 1").await.is_err()); + } + + #[tokio::test] + async fn schema_tree_nodes_have_unique_ids() { + let svc = MockConnectionService::ready(); + let tree = svc.schema_tree().await.expect("schema"); + let mut ids = Vec::new(); + fn walk<'a>(ns: &'a [SchemaNode], out: &mut Vec<&'a str>) { + for n in ns { + out.push(n.id.as_str()); + walk(&n.children, out); + } + } + walk(&tree, &mut ids); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(total, ids.len(), "schema node ids must be unique"); + } + + #[tokio::test] + async fn schema_tree_ready_fixture_is_at_least_two_levels_deep_with_path_like_ids() { + // Expectation authored independently: the brief requires a fixture + // that is structurally (not accidentally) unique, rooted at "db" + // with a "db/users" descendant. + let svc = MockConnectionService::ready(); + let tree = svc.schema_tree().await.expect("schema"); + let root = tree.first().expect("schema tree has a root"); + assert_eq!(root.id, "db"); + assert!( + root.children.iter().any(|c| c.id == "db/users"), + "root must have a db/users child" + ); + let users = root + .children + .iter() + .find(|c| c.id == "db/users") + .expect("db/users child exists"); + assert!( + !users.children.is_empty(), + "db/users must have its own children for a two-level tree" + ); + } + + #[tokio::test] + async fn schema_tree_empty_is_empty() { + let svc = MockConnectionService::empty(); + let s = AsyncState::from_value(Some(svc.schema_tree().await)); + assert!(s.is_empty()); + } + + #[tokio::test] + async fn schema_tree_erroring_is_err() { + let svc = MockConnectionService::erroring(); + let s = AsyncState::from_value(Some(svc.schema_tree().await)); + assert!(s.error_message().is_some()); + } +} diff --git a/nodedb-studio/src/state/connection.rs b/nodedb-studio/src/state/connection.rs index 3a377f9..045bd0e 100644 --- a/nodedb-studio/src/state/connection.rs +++ b/nodedb-studio/src/state/connection.rs @@ -2,11 +2,12 @@ //! //! Identity in NodeDB-Studio is per-connection, NOT global. There is no //! "Studio account": switching connections swaps the NodeDB user, role, avatar -//! letter, and the capability flags that reshape the entire shell. See -//! CLAUDE.md "Per-connection identity" and "Capability-driven shell". +//! letter, and the capability flags that reshape the entire shell. use serde::{Deserialize, Serialize}; +use crate::services::error::StudioError; + /// A single capability flag, used both as the struct fields below and as a /// key for the mockup's `data-cap` hide/show behavior and notification gating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -86,3 +87,101 @@ impl ActiveConnection { .unwrap_or('?') } } + +/// The last failed `connect()`, surfaced app-wide. +/// +/// Connect is initiated from three places, and two of them (the command palette +/// and the switch popover) close themselves the moment the attempt starts. An +/// error signal owned by those components would be dropped before it could +/// render, so the surface has to outlive them and live at the app root. +/// +/// A newtype rather than a bare `Signal>` because Dioxus +/// keys context by type: a second bare-error provider added later would bind to +/// this one instead of its own, silently. +pub struct ConnectError(pub Option); + +/// Reconcile the active-connection slot with the result of a `connect()`. +/// +/// On `Ok` the session becomes active. On `Err` the existing session is left +/// exactly as it was: a failed switch must not disconnect the user from the +/// connection they still have. The error is returned so the caller can surface +/// it rather than log it, which is the whole point — a Connect button that +/// fails silently is indistinguishable from one that is broken. +pub fn apply_connect( + active: &mut Option, + result: Result, +) -> Option { + match result { + Ok(session) => { + *active = Some(session); + None + } + Err(e) => Some(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn caps() -> Capabilities { + Capabilities { + graph: false, + vector: false, + streams: false, + timeseries: false, + spatial: false, + fts: false, + sync: false, + cluster: false, + readonly: false, + } + } + + fn session(name: &str) -> ActiveConnection { + ActiveConnection { + name: name.into(), + sub: "nodedb".into(), + user: "alice".into(), + role: "admin".into(), + capabilities: caps(), + databases: vec!["main".into()], + current_database: "main".into(), + } + } + + #[test] + fn apply_connect_ok_activates_the_session() { + let mut active = None; + let err = apply_connect(&mut active, Ok(session("local-dev"))); + assert!(err.is_none()); + assert_eq!(active.expect("session must be active").name, "local-dev"); + } + + #[test] + fn apply_connect_err_returns_the_error_for_the_caller_to_render() { + let mut active = None; + let err = apply_connect(&mut active, Err(StudioError::MissingUsername)); + assert!( + matches!(err, Some(StudioError::MissingUsername)), + "the error must reach the caller, not be swallowed" + ); + assert!(active.is_none()); + } + + /// A failed switch must not disconnect the user from the connection they + /// still have. Against an implementation that clears `active` on Err, this + /// test fails. + #[test] + fn apply_connect_err_keeps_the_existing_session() { + let mut active = Some(session("local-dev")); + let err = apply_connect(&mut active, Err(StudioError::NotConnected)); + assert!(err.is_some()); + assert_eq!( + active + .expect("previous session must survive a failed switch") + .name, + "local-dev" + ); + } +} diff --git a/nodedb-studio/src/state/connections_registry.rs b/nodedb-studio/src/state/connections_registry.rs index 54121ce..1cad126 100644 --- a/nodedb-studio/src/state/connections_registry.rs +++ b/nodedb-studio/src/state/connections_registry.rs @@ -69,3 +69,59 @@ impl SavedConnection { }) } } + +/// Identity supplied at connect time. Studio never defaults the username: the +/// client would silently fall back to `admin`, so a blank field must be a +/// validation error surfaced in the connect form. +/// +/// `Debug` is hand-written, not derived: a derived impl would print +/// `password` verbatim once the connect form starts populating it. The +/// redaction marker is rendered unconditionally (`Some` and `None` look +/// identical) so a `{:?}` print cannot even leak whether a password was set. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Credentials { + pub username: String, + pub password: Option, +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("username", &self.username) + .field("password", &"") + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_never_leaks_a_populated_password() { + let creds = Credentials { + username: "alice".into(), + password: Some("hunter2".into()), + }; + let printed = format!("{creds:?}"); + assert!(!printed.contains("hunter2"), "password leaked: {printed}"); + assert!(printed.contains("")); + } + + #[test] + fn debug_reads_identically_whether_a_password_is_set_or_not() { + // The redaction marker must not double as a presence/absence signal. + let with_password = Credentials { + username: "alice".into(), + password: Some("hunter2".into()), + }; + let without_password = Credentials { + username: "alice".into(), + password: None, + }; + assert_eq!( + format!("{with_password:?}"), + format!("{without_password:?}") + ); + } +} diff --git a/nodedb-studio/src/state/notifications.rs b/nodedb-studio/src/state/notifications.rs index f73f0c5..9f4f150 100644 --- a/nodedb-studio/src/state/notifications.rs +++ b/nodedb-studio/src/state/notifications.rs @@ -5,6 +5,8 @@ //! logic in one place so the bell badge and the popover list agree. use crate::models::notification::Notification; +use crate::services::async_state::AsyncState; +use crate::services::error::StudioError; use crate::state::connection::Capabilities; /// Notifications visible for the given capabilities: an item is hidden when it @@ -24,6 +26,28 @@ pub fn unread_count(items: &[Notification], caps: &Capabilities) -> usize { visible(items, caps).filter(|n| n.unread).count() } +/// Reconcile the local store with the result of a `mark_all_read` write. +/// +/// On `Ok` the loaded list is cleared so the badge drops immediately. On `Err` +/// the loaded list is left exactly as it was: the write failed, the read did +/// not, and clearing badges for a write the server rejected is the lie this +/// function exists to prevent. The write error is returned so the caller can +/// show it beside the still-correct list rather than in place of it. +pub fn apply_mark_all_read( + store: &mut AsyncState>, + write: Result<(), StudioError>, +) -> Option { + match write { + Ok(()) => { + if let Some(items) = store.loaded_mut() { + mark_all_read(items); + } + None + } + Err(e) => Some(e), + } +} + /// Clear the unread flag on every notification (the "mark all read" action). /// Mutates the shared store in place so the bell badge and popover stay in sync. pub fn mark_all_read(items: &mut [Notification]) { @@ -79,4 +103,36 @@ mod tests { mark_read(&mut items, "missing"); assert!(items[0].unread); } + fn loaded(items: Vec) -> AsyncState> { + AsyncState::from_value(Some(Ok(items))) + } + + #[test] + fn apply_mark_all_read_ok_clears_every_unread() { + let mut store = loaded(vec![notif("a", true), notif("b", true)]); + let err = apply_mark_all_read(&mut store, Ok(())); + assert!(err.is_none()); + assert!( + store + .loaded() + .expect("still loaded") + .iter() + .all(|n| !n.unread) + ); + } + + /// The property the popover used to violate: a failed write must leave + /// the loaded list untouched, so the badge cannot show "all clear" for + /// items the server still holds unread. + #[test] + fn apply_mark_all_read_err_leaves_list_intact_and_returns_error() { + let mut store = loaded(vec![notif("a", true), notif("b", false)]); + let err = apply_mark_all_read(&mut store, Err(StudioError::NotConnected)); + assert!(matches!(err, Some(StudioError::NotConnected))); + let items = store + .loaded() + .expect("a failed write must not discard the loaded list"); + assert!(items[0].unread, "unread flag must survive a failed write"); + assert!(!items[1].unread); + } } diff --git a/nodedb-studio/src/state/preferences.rs b/nodedb-studio/src/state/preferences.rs index 586ab82..649d766 100644 --- a/nodedb-studio/src/state/preferences.rs +++ b/nodedb-studio/src/state/preferences.rs @@ -1,8 +1,8 @@ //! App-level preferences (theme, fonts, keyboard, telemetry). //! //! These are global to Studio, NOT per-connection, and live behind the -//! Preferences modal — never in the studio rail. See CLAUDE.md -//! "Settings vs preferences". +//! Preferences modal, never in the studio rail. Connection-scoped settings +//! belong with the connection; anything global to Studio belongs here. use serde::{Deserialize, Serialize}; diff --git a/nodedb-studio/src/views/connection_manager.rs b/nodedb-studio/src/views/connection_manager.rs index 12dd77a..3c1b8e1 100644 --- a/nodedb-studio/src/views/connection_manager.rs +++ b/nodedb-studio/src/views/connection_manager.rs @@ -3,11 +3,12 @@ use std::rc::Rc; +use dioxus::core::spawn_forever; use dioxus::prelude::*; use crate::services::backend::Backend; -use crate::state::connection::ActiveConnection; -use crate::state::connections_registry::{ConnStatus, SavedConnection}; +use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; +use crate::state::connections_registry::{ConnStatus, Credentials, SavedConnection}; use crate::state::ui::ModalKind; #[component] @@ -16,6 +17,7 @@ pub fn ConnectionManager() -> Element { let mut active = use_context::>>(); let mut modal = use_context::>>(); let service = use_context::>(); + let mut connect_error = use_context::>(); rsx! { div { class: "conn-manager", @@ -49,15 +51,34 @@ pub fn ConnectionManager() -> Element { conn: conn.clone(), on_connect: { let service = service.clone(); + // The stored profile IS this card's explicit + // username. A profile-less entry yields a blank, + // which the seam rejects with MissingUsername and + // the app root renders, rather than defaulting to + // `admin`. New connections collect a username in + // the modal (see modals/new_connection.rs). + let creds = Credentials { + username: conn + .profile + .as_ref() + .map(|p| p.user.clone()) + .unwrap_or_default(), + password: None, + }; move |name: String| { // Async at the seam: clone the Rc into the task and - // set `active` (Copy) only after the await resolves. + // write the signals (Copy) only after the await + // resolves. Clearing the error first makes a stale + // failure disappear the moment a new attempt starts. let service = service.clone(); - spawn(async move { - if let Ok(session) = service.connect(&name).await { - active.set(Some(session)); - } - // Err case (e.g. offline): surfaced in a later wiring phase. + let creds = creds.clone(); + connect_error.set(ConnectError(None)); + // spawn_forever so the task survives this + // screen being swapped for the studio shell. + spawn_forever(async move { + let result = service.connect(&name, &creds).await; + let err = apply_connect(&mut active.write(), result); + connect_error.set(ConnectError(err)); }); } }, diff --git a/nodedb-studio/src/views/explorer/mod.rs b/nodedb-studio/src/views/explorer/mod.rs index 581b6b0..62b3832 100644 --- a/nodedb-studio/src/views/explorer/mod.rs +++ b/nodedb-studio/src/views/explorer/mod.rs @@ -4,4 +4,4 @@ pub mod sidebar; mod view; pub mod viewers; -pub use view::{Explorer, Selected}; +pub use view::{Explorer, Selected, default_selection, selection_still_present}; diff --git a/nodedb-studio/src/views/explorer/sidebar.rs b/nodedb-studio/src/views/explorer/sidebar.rs index 45c3029..b86f252 100644 --- a/nodedb-studio/src/views/explorer/sidebar.rs +++ b/nodedb-studio/src/views/explorer/sidebar.rs @@ -1,23 +1,74 @@ -//! Explorer sidebar: collections grouped by storage mode. Clicking a -//! collection updates the shared selection, which swaps the viewer pane. +//! Explorer sidebar: collections grouped by storage mode, read through the +//! seam. Split into a fetch wrapper (`ExplorerSidebar`, owns the async read) +//! and a pure presentational component (`SidebarGroups`, takes `AsyncState` +//! as input) so the four states are render-testable without a runtime. +//! +//! Clicking a collection updates the shared selection, which swaps the +//! viewer pane. `ExplorerSidebar` also owns defaulting that selection: once +//! `collection_groups()` loads with data and nothing is selected yet, it +//! picks the first collection of the first group (`default_selection`) — +//! there is no selection at all while loading, empty, or errored. + +use std::rc::Rc; use dioxus::prelude::*; -use crate::data::mock; -use crate::models::collection::{Collection, StorageMode}; -use crate::views::explorer::Selected; +use crate::components::async_view::AsyncView; +use crate::models::explorer::CollectionGroup; +use crate::services::async_state::AsyncState; +use crate::services::backend::Backend; +use crate::views::explorer::{Selected, default_selection, selection_still_present}; #[component] -pub fn ExplorerSidebar(selected: Signal) -> Element { - // Group collections by mode, preserving the mock's order. - let collections = use_hook(mock::explorer_collections); - let mut groups: Vec<(StorageMode, Vec)> = Vec::new(); - for col in &collections { - match groups.last_mut() { - Some((mode, items)) if *mode == col.mode => items.push(col.clone()), - _ => groups.push((col.mode, vec![col.clone()])), +pub fn ExplorerSidebar(selected: Signal>) -> Element { + let backend = use_context::>(); + let mut groups = use_resource(move || { + let backend = backend.clone(); + async move { backend.collection_groups().await } + }); + + // Clone the resource value out of its guard immediately — never hold a + // read guard across an await; there is none here. + let state = AsyncState::from_value(groups.read().clone()); + + // Default the selection once real data is in, but only while nothing has + // been picked yet — a later reload (`on_retry`) must never clobber a + // selection the user already made. Reads `groups` (the resource itself, + // not the derived `state` local) inside the effect so it reruns exactly + // when the resource changes; `selected.peek()` reads without subscribing. + // + // A pick is kept only while it still exists. If a reload returns a set + // without the selected collection, keeping it would leave the viewer + // header naming a collection no sidebar row matches — the same + // phantom-collection symptom the hardcoded default used to produce. + use_effect(move || { + let value = groups.read().clone(); + let Some(Ok(gs)) = value else { + return; + }; + if selection_still_present(&gs, selected.peek().as_ref()) { + return; } + selected.set(default_selection(&gs)); + }); + + rsx! { + SidebarGroups { state, selected, on_retry: move |_| groups.restart() } } +} + +#[derive(Props, Clone, PartialEq)] +pub struct SidebarGroupsProps { + pub state: AsyncState>, + pub selected: Signal>, + #[props(default)] + pub on_retry: EventHandler<()>, +} + +#[component] +pub fn SidebarGroups(props: SidebarGroupsProps) -> Element { + let state = &props.state; + let mut selected = props.selected; rsx! { aside { class: "explorer-sidebar", @@ -25,27 +76,40 @@ pub fn ExplorerSidebar(selected: Signal) -> Element { input { placeholder: "Filter collections…" } button { class: "btn small ghost", title: "New collection", "+" } } - for (mode, items) in groups { - div { class: "engine-group", - div { class: "engine-group-header", - span { class: "chev", "▾" } - " {mode.label().to_uppercase()}" - } - for col in items { - { - let sel = selected.read(); - let is_active = sel.name == col.name && sel.mode == col.mode; - drop(sel); - let item_class = if is_active { "collection active" } else { "collection" }; - let name = col.name.clone(); - let mode = col.mode; - rsx! { - div { - class: "{item_class}", - onclick: move |_| selected.set(Selected { name: name.clone(), mode }), - span { class: "ico", "{col.mode.icon_letter()}" } - " {col.name} " - span { class: "count", "{col.count}" } + AsyncView { + loading: state.is_loading(), + empty: state.is_empty(), + error: state.error_message(), + retriable: state.is_retriable(), + on_retry: move |_| props.on_retry.call(()), + empty_message: "No collections.".to_string(), + } + if let Some(groups) = state.loaded() { + for group in groups { + div { key: "{group.mode.key()}", class: "engine-group", + div { class: "engine-group-header", + span { class: "chev", "▾" } + " {group.mode.label().to_uppercase()}" + } + for col in &group.collections { + { + let sel = selected.read(); + let is_active = sel + .as_ref() + .is_some_and(|s| s.name == col.name && s.mode == col.mode); + drop(sel); + let item_class = if is_active { "collection active" } else { "collection" }; + let name = col.name.clone(); + let mode = col.mode; + rsx! { + div { + key: "{col.name}", + class: "{item_class}", + onclick: move |_| selected.set(Some(Selected { name: name.clone(), mode })), + span { class: "ico", "{col.mode.icon_letter()}" } + " {col.name} " + span { class: "count", "{col.count}" } + } } } } @@ -55,3 +119,131 @@ pub fn ExplorerSidebar(selected: Signal) -> Element { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::collection::{Collection, StorageMode}; + use crate::services::error::StudioError; + + fn render(app: fn() -> Element) -> String { + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + fn sample_groups() -> Vec { + vec![ + CollectionGroup { + mode: StorageMode::Document, + collections: vec![Collection { + name: "users".to_string(), + mode: StorageMode::Document, + count: "12,481".to_string(), + }], + }, + CollectionGroup { + mode: StorageMode::Vector, + collections: vec![Collection { + name: "embeddings".to_string(), + mode: StorageMode::Vector, + count: "2.4M".to_string(), + }], + }, + ] + } + + fn selected_signal() -> Signal> { + Signal::new(Some(Selected { + name: "users".to_string(), + mode: StorageMode::Document, + })) + } + + fn no_selection_signal() -> Signal> { + Signal::new(None) + } + + fn app_loading() -> Element { + rsx! { + SidebarGroups { + state: AsyncState::Loading, + selected: selected_signal(), + } + } + } + + fn app_empty() -> Element { + rsx! { + SidebarGroups { + state: AsyncState::from_value(Some(Ok(Vec::::new()))), + selected: selected_signal(), + } + } + } + + fn app_error() -> Element { + rsx! { + SidebarGroups { + state: AsyncState::Error(StudioError::NotConnected), + selected: selected_signal(), + } + } + } + + fn app_ready() -> Element { + rsx! { + SidebarGroups { + state: AsyncState::from_value(Some(Ok(sample_groups()))), + selected: selected_signal(), + } + } + } + + fn app_ready_no_selection() -> Element { + rsx! { + SidebarGroups { + state: AsyncState::from_value(Some(Ok(sample_groups()))), + selected: no_selection_signal(), + } + } + } + + #[test] + fn loading_state_renders_spinner() { + let html = render(app_loading); + assert!(html.contains("async-loading")); + } + + #[test] + fn empty_state_renders_message() { + let html = render(app_empty); + assert!(html.contains("async-empty")); + assert!(html.contains("No collections")); + } + + #[test] + fn error_state_renders_error() { + let html = render(app_error); + assert!(html.contains("async-error")); + } + + #[test] + fn ready_state_renders_keyed_groups_and_collections() { + let html = render(app_ready); + assert!(html.contains("engine-group")); + assert!(html.contains("DOCUMENT")); + assert!(html.contains("VECTOR")); + assert!(html.contains("users")); + assert!(html.contains("embeddings")); + // The selected collection ("users") renders with the active class. + assert!(html.contains("collection active")); + } + + #[test] + fn no_selection_highlights_no_row() { + // Honest "nothing picked yet" state: no row gets the active class. + let html = render(app_ready_no_selection); + assert!(!html.contains("collection active")); + } +} diff --git a/nodedb-studio/src/views/explorer/view.rs b/nodedb-studio/src/views/explorer/view.rs index d092913..70a7efa 100644 --- a/nodedb-studio/src/views/explorer/view.rs +++ b/nodedb-studio/src/views/explorer/view.rs @@ -4,10 +4,17 @@ //! collections grouped by mode; selecting one swaps the main pane to that //! mode's purpose-built viewer. The selected collection is shared between the //! sidebar and the viewer pane via a signal owned here. +//! +//! There is no fabricated default selection. Until the sidebar's seam read +//! resolves — or if it resolves to no collections at all — `selected` stays +//! `None` and the main pane says so honestly. Once `collection_groups()` +//! loads with data, `ExplorerSidebar` defaults `selected` to the first +//! collection of the first group via `default_selection` below. use dioxus::prelude::*; use crate::models::collection::StorageMode; +use crate::models::explorer::CollectionGroup; use crate::views::explorer::sidebar::ExplorerSidebar; use crate::views::explorer::viewers::document::DocumentViewer; use crate::views::explorer::viewers::fts::FtsViewer; @@ -25,12 +32,39 @@ pub struct Selected { pub mode: StorageMode, } +/// The Explorer's default selection: the first collection of the first +/// group, in `collection_groups()`'s own display order. `None` when there +/// are no groups (loading, empty, or errored) — there is no fallback name, +/// because any hardcoded name silently rots the moment the fixture changes +/// (see the `events` regression this replaced: the collection it named had +/// already been renamed out of the fixture). +pub fn default_selection(groups: &[CollectionGroup]) -> Option { + let group = groups.first()?; + let collection = group.collections.first()?; + Some(Selected { + name: collection.name.clone(), + mode: collection.mode, + }) +} + +/// Whether `selected` still names a collection present in `groups`. +/// +/// A reload can return a set the current pick is no longer in (renamed, +/// dropped, or a different connection). Keeping it leaves the viewer header +/// naming a collection no sidebar row matches, which is the phantom-collection +/// symptom the hardcoded default used to produce. +pub fn selection_still_present(groups: &[CollectionGroup], selected: Option<&Selected>) -> bool { + let Some(sel) = selected else { + return false; + }; + groups + .iter() + .any(|g| g.collections.iter().any(|c| c.name == sel.name)) +} + #[component] pub fn Explorer() -> Element { - let selected = use_signal(|| Selected { - name: "events".to_string(), - mode: StorageMode::Document, - }); + let selected = use_signal(|| None::); let sel = selected.read().clone(); rsx! { @@ -38,32 +72,154 @@ pub fn Explorer() -> Element { div { class: "explorer", ExplorerSidebar { selected } div { class: "explorer-main", - div { class: "viewer-header", - h2 { - span { "{sel.name}" } - span { class: "sub", "{sel.mode.key()}" } - } - div { class: "viewer-actions", - button { class: "btn small", "Schema" } - button { class: "btn small", "Indexes" } - button { class: "btn small", "Export" } - button { class: "btn small primary", "+ Insert" } + if let Some(sel) = sel { + div { class: "viewer-header", + h2 { + span { "{sel.name}" } + span { class: "sub", "{sel.mode.key()}" } + } + div { class: "viewer-actions", + button { class: "btn small", "Schema" } + button { class: "btn small", "Indexes" } + button { class: "btn small", "Export" } + button { class: "btn small primary", "+ Insert" } + } } - } - div { class: "viewer-body", - match sel.mode { - StorageMode::Document => rsx! { DocumentViewer {} }, - StorageMode::Strict => rsx! { StrictViewer {} }, - StorageMode::Vector => rsx! { VectorViewer {} }, - StorageMode::Graph => rsx! { GraphViewer {} }, - StorageMode::Timeseries => rsx! { TimeseriesViewer {} }, - StorageMode::Kv => rsx! { KvViewer {} }, - StorageMode::Spatial => rsx! { SpatialViewer {} }, - StorageMode::Fts => rsx! { FtsViewer {} }, + div { class: "viewer-body", + match sel.mode { + StorageMode::Document => rsx! { DocumentViewer {} }, + StorageMode::Strict => rsx! { StrictViewer {} }, + StorageMode::Vector => rsx! { VectorViewer {} }, + StorageMode::Graph => rsx! { GraphViewer {} }, + StorageMode::Timeseries => rsx! { TimeseriesViewer {} }, + StorageMode::Kv => rsx! { KvViewer {} }, + StorageMode::Spatial => rsx! { SpatialViewer {} }, + StorageMode::Fts => rsx! { FtsViewer {} }, + } } + } else { + div { class: "async-empty", "Select a collection to view its data." } } } } } } } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::models::collection::Collection; + use crate::services::connection_service::MockConnectionService; + use crate::services::explorer_data::ExplorerData; + + fn groups_with(names: &[&str]) -> Vec { + vec![CollectionGroup { + mode: StorageMode::Document, + collections: names + .iter() + .map(|n| Collection { + name: (*n).to_string(), + mode: StorageMode::Document, + count: "1".to_string(), + }) + .collect(), + }] + } + + /// A pick that survived a reload must still exist in the new set. Against + /// an implementation that keeps any non-None selection, the second case + /// fails and the viewer header names a collection no sidebar row matches. + #[test] + fn selection_is_kept_only_while_the_collection_exists() { + let groups = groups_with(&["events", "orders"]); + let pick = Selected { + name: "orders".to_string(), + mode: StorageMode::Document, + }; + assert!(selection_still_present(&groups, Some(&pick))); + + let after_rename = groups_with(&["events", "orders_v2"]); + assert!( + !selection_still_present(&after_rename, Some(&pick)), + "a reload that dropped `orders` must not keep it selected" + ); + } + + #[test] + fn no_selection_is_never_present() { + assert!(!selection_still_present(&groups_with(&["events"]), None)); + } + + #[test] + fn default_selection_is_none_for_no_groups() { + assert!(default_selection(&[]).is_none()); + } + + #[test] + fn default_selection_is_none_when_the_first_group_has_no_collections() { + let groups = [CollectionGroup { + mode: StorageMode::Document, + collections: Vec::new(), + }]; + assert!(default_selection(&groups).is_none()); + } + + #[test] + fn default_selection_is_the_first_collection_of_the_first_group() { + let groups = [ + CollectionGroup { + mode: StorageMode::Document, + collections: vec![ + Collection { + name: "users".to_string(), + mode: StorageMode::Document, + count: "1".to_string(), + }, + Collection { + name: "orders".to_string(), + mode: StorageMode::Document, + count: "2".to_string(), + }, + ], + }, + CollectionGroup { + mode: StorageMode::Vector, + collections: vec![Collection { + name: "embeddings".to_string(), + mode: StorageMode::Vector, + count: "3".to_string(), + }], + }, + ]; + let selection = default_selection(&groups).expect("groups are non-empty"); + assert_eq!(selection.name, "users"); + assert_eq!(selection.mode, StorageMode::Document); + } + + #[tokio::test] + async fn default_selection_names_a_collection_that_actually_exists() { + // Regression test: the Explorer used to hardcode its default + // selection as "events" / Document, a name that does not exist in + // `collection_groups()` — opening the Explorer showed a viewer + // header for a collection the sidebar didn't have, with no row + // highlighted. Reads the real fixture through the seam (not a + // test-local stand-in like `sidebar`'s `sample_groups()`), so a + // future fixture change can't silently reintroduce the same bug. + let svc = MockConnectionService::ready(); + let groups = svc.collection_groups().await.expect("ready yields groups"); + let selection = default_selection(&groups).expect("fixture is non-empty"); + let exists = groups.iter().any(|g| { + g.collections + .iter() + .any(|c| c.name == selection.name && c.mode == selection.mode) + }); + assert!( + exists, + "default selection {:?}/{:?} must name a real collection", + selection.name, + selection.mode.key() + ); + } +} diff --git a/nodedb-studio/src/views/query.rs b/nodedb-studio/src/views/query.rs index 07fca49..0b0470d 100644 --- a/nodedb-studio/src/views/query.rs +++ b/nodedb-studio/src/views/query.rs @@ -1,7 +1,7 @@ //! Query workspace: schema tree + editor + results. //! -//! The editor is a static highlighted `
` placeholder (CLAUDE.md: no real
-//! code editor — CodeMirror/Monaco would slot in here later).
+//! The editor is a static highlighted `
` placeholder. There is no real
+//! code editor yet; CodeMirror or Monaco would slot in here later.
 
 use dioxus::prelude::*;
 
diff --git a/nodedb-studio/src/views/streams/notify.rs b/nodedb-studio/src/views/streams/notify.rs
index 68ce5fb..29b8e04 100644
--- a/nodedb-studio/src/views/streams/notify.rs
+++ b/nodedb-studio/src/views/streams/notify.rs
@@ -1,40 +1,146 @@
-//! Streams · LISTEN/NOTIFY: channel list + a live pub/sub tail. Payloads are
-//! native documents (see `data::mock`), serialized to JSON here for display.
+//! Streams · LISTEN/NOTIFY: channel list + a live pub/sub tail, read through
+//! the seam. Split into a fetch wrapper (`StreamsNotify`, owns the two async
+//! reads) and a pure presentational component (`NotifyPanes`, takes
+//! `AsyncState` as input) so the four states are render-testable.
+//!
+//! NodeDB has no LISTEN/NOTIFY: no `LISTEN` keyword in `nodedb-sql`, no
+//! pub/sub, no client path. The only `NOTIFY` in the server is
+//! `CREATE ALERT … NOTIFY TOPIC/WEBHOOK`, which is alert routing. So the seam
+//! methods behind this screen stand for a feature that does not exist yet, and
+//! the models carry only fields a future implementation could actually fill.
+//! The mockup showed a publisher identity per message; nothing in NodeDB can
+//! produce one, so the tail shows the channel instead of inventing a field.
+//!
+//! Which channel is selected is view state, not a seam field: the server has
+//! no opinion about what the user clicked. It defaults to the first loaded
+//! channel, the same way the Explorer derives its default selection.
+
+use std::rc::Rc;
 
 use dioxus::prelude::*;
 
-use crate::data::mock;
+use crate::components::async_view::AsyncView;
+use crate::models::streams::{NotifyChannel, NotifyMessage};
+use crate::services::async_state::AsyncState;
+use crate::services::backend::Backend;
+
+/// The default selected channel: the first one the seam returned, in its own
+/// order. `None` while loading, on error, or when there are no channels —
+/// never a hardcoded name, which rots silently the moment the fixture changes.
+pub fn default_channel(channels: &[NotifyChannel]) -> Option {
+    channels.first().map(|c| c.name.clone())
+}
 
 #[component]
 pub fn StreamsNotify() -> Element {
-    let channels = mock::notify_channels();
-    // (time, source, payload-json)
-    let rows: Vec<(&str, &str, String)> = mock::notify_messages()
-        .into_iter()
-        .map(|m| {
-            (
-                m.time,
-                m.source,
-                sonic_rs::to_string(&m.payload).unwrap_or_default(),
-            )
+    let backend = use_context::>();
+
+    let mut channels = use_resource({
+        let backend = backend.clone();
+        move || {
+            let backend = backend.clone();
+            async move { backend.notify_channels().await }
+        }
+    });
+    let mut messages = use_resource(move || {
+        let backend = backend.clone();
+        async move { backend.notify_messages().await }
+    });
+
+    // Explicit user choice; `None` means "whatever the load defaults to".
+    let mut picked = use_signal(|| None::);
+
+    // Clone out of the resource guards immediately — never hold one across a
+    // render or an await.
+    let channels_state = AsyncState::from_value(channels.read().clone());
+    let messages_state = AsyncState::from_value(messages.read().clone());
+
+    let selected = picked.read().clone().or_else(|| {
+        channels_state
+            .loaded()
+            .and_then(|list| default_channel(list))
+    });
+
+    rsx! {
+        NotifyPanes {
+            channels: channels_state,
+            messages: messages_state,
+            selected,
+            on_pick: move |name: String| picked.set(Some(name)),
+            on_retry_channels: move |_| channels.restart(),
+            on_retry_messages: move |_| messages.restart(),
+        }
+    }
+}
+
+#[component]
+fn NotifyPanes(
+    channels: AsyncState>,
+    messages: AsyncState>,
+    selected: Option,
+    on_pick: EventHandler,
+    on_retry_channels: EventHandler<()>,
+    on_retry_messages: EventHandler<()>,
+) -> Element {
+    let channel_list = channels.loaded().cloned().unwrap_or_default();
+    // Only a loaded list has a count. While loading or after an error the header
+    // would otherwise read "Channels (0)" directly above the spinner or error.
+    let count = channels.loaded().map(|c| c.len());
+    // Listener count for the selected channel, from the loaded list rather than
+    // a literal, so the toolbar cannot disagree with the sidebar.
+    let listeners = selected.as_ref().and_then(|name| {
+        channel_list
+            .iter()
+            .find(|c| &c.name == name)
+            .map(|c| c.subscribers.clone())
+    });
+    // The seam returns the whole tail; scope it to the selection here so
+    // picking a channel means something. A channel with no traffic renders an
+    // empty tail, which is the truth, not a blank pane.
+    let rows: Vec = messages
+        .loaded()
+        .map(|all| {
+            all.iter()
+                .filter(|m| selected.as_ref().is_none_or(|s| &m.channel == s))
+                .cloned()
+                .collect()
         })
-        .collect();
+        .unwrap_or_default();
+
     rsx! {
         div { style: "display: grid; grid-template-columns: 260px 1fr; overflow: hidden;",
             div { style: "background: var(--bg-secondary); border-right: 0.5px solid var(--border-mid); padding: 10px;",
-                div { class: "eyebrow", style: "padding: 6px 10px;", "Channels (14)" }
-                for c in channels {
-                    div { class: if c.active { "collection active" } else { "collection" },
+                div { class: "eyebrow", style: "padding: 6px 10px;",
+                    if let Some(n) = count { "Channels ({n})" } else { "Channels" }
+                }
+                AsyncView {
+                    loading: channels.is_loading(),
+                    empty: channels.is_empty(),
+                    error: channels.error_message(),
+                    retriable: channels.is_retriable(),
+                    on_retry: move |_| on_retry_channels.call(()),
+                    empty_message: "No channels.".to_string(),
+                }
+                for c in channel_list {
+                    div {
+                        key: "{c.id}",
+                        class: if selected.as_deref() == Some(c.name.as_str()) { "collection active" } else { "collection" },
+                        onclick: {
+                            let name = c.name.clone();
+                            move |_| on_pick.call(name.clone())
+                        },
                         span { class: "ico", "#" }
                         " {c.name} "
-                        span { class: "count", "{c.listeners}" }
+                        span { class: "count", "{c.subscribers}" }
                     }
                 }
             }
             div { class: "live-tail",
                 div { class: "tail-toolbar",
-                    strong { style: "font-size:13px;", "user_events" }
-                    span { class: "pill info", span { class: "dot" } "12 listeners" }
+                    strong { style: "font-size:13px;", "{selected.clone().unwrap_or_default()}" }
+                    if let Some(n) = listeners {
+                        span { class: "pill info", span { class: "dot" } "{n} listeners" }
+                    }
                     div { style: "margin-left:auto; display:flex; gap:6px;",
                         input {
                             placeholder: "payload filter",
@@ -44,22 +150,245 @@ pub fn StreamsNotify() -> Element {
                     }
                 }
                 div { class: "tail-body",
-                    for r in rows {
-                        div { class: "tail-row",
-                            span { class: "time", "{r.0}" }
+                    AsyncView {
+                        loading: messages.is_loading(),
+                        // Driven by the FILTERED rows, not the raw read: the seam
+                        // returns every channel's messages, so a channel with no
+                        // traffic has a non-empty read and zero rows. Keying this
+                        // off `messages.is_empty()` renders the blank pane this
+                        // screen exists to avoid. Loading and Error win, so the
+                        // spinner and the error are not replaced by "No messages".
+                        empty: rows.is_empty()
+                            && !messages.is_loading()
+                            && messages.error_message().is_none(),
+                        error: messages.error_message(),
+                        retriable: messages.is_retriable(),
+                        on_retry: move |_| on_retry_messages.call(()),
+                        empty_message: "No messages.".to_string(),
+                    }
+                    for m in rows {
+                        div { key: "{m.id}", class: "tail-row",
+                            span { class: "time", "{m.at}" }
                             span { class: "op ins", "NOTIFY" }
-                            span { class: "coll", "{r.1}" }
-                            span { class: "payload", "{r.2}" }
+                            span { class: "coll", "{m.channel}" }
+                            span { class: "payload", "{m.payload_json}" }
                         }
                     }
                 }
                 div { class: "tail-footer",
                     span { class: "tail-pulse" }
                     span { "following" }
-                    span { "throughput: 84 msg/s" }
-                    span { "since: 04:18:00" }
                 }
             }
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::services::error::StudioError;
+
+    fn render(app: fn() -> Element) -> String {
+        let mut dom = VirtualDom::new(app);
+        dom.rebuild_in_place();
+        dioxus_ssr::render(&dom)
+    }
+
+    fn channel(id: &str, name: &str, subs: &str) -> NotifyChannel {
+        NotifyChannel {
+            id: id.into(),
+            name: name.into(),
+            subscribers: subs.into(),
+        }
+    }
+
+    fn message(id: &str, chan: &str) -> NotifyMessage {
+        NotifyMessage {
+            id: id.into(),
+            channel: chan.into(),
+            at: "04:23:18.041".into(),
+            payload_json: "{\"event\":\"login\"}".into(),
+        }
+    }
+
+    fn sample_channels() -> Vec {
+        vec![
+            channel("channel-1", "user_events", "12"),
+            channel("channel-2", "deploy_hooks", "3"),
+        ]
+    }
+
+    #[test]
+    fn default_channel_is_the_first_one_the_seam_returned() {
+        assert_eq!(
+            default_channel(&sample_channels()).as_deref(),
+            Some("user_events")
+        );
+    }
+
+    #[test]
+    fn default_channel_is_none_when_there_are_no_channels() {
+        assert!(default_channel(&[]).is_none());
+    }
+
+    fn app_loading() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::Loading,
+                messages: AsyncState::Loading,
+                selected: None,
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    fn app_empty() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::from_value(Some(Ok(Vec::::new()))),
+                messages: AsyncState::from_value(Some(Ok(Vec::::new()))),
+                selected: None,
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    fn app_error() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::Error(StudioError::NotConnected),
+                messages: AsyncState::Error(StudioError::NotConnected),
+                selected: None,
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    fn app_ready() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::from_value(Some(Ok(sample_channels()))),
+                messages: AsyncState::from_value(Some(Ok(vec![
+                    message("notify-0", "user_events"),
+                    message("notify-1", "deploy_hooks"),
+                ]))),
+                selected: Some("user_events".to_string()),
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    #[test]
+    fn loading_state_renders_spinner() {
+        assert!(render(app_loading).contains("async-loading"));
+    }
+
+    #[test]
+    fn empty_state_renders_message() {
+        let html = render(app_empty);
+        assert!(html.contains("No channels."));
+        assert!(html.contains("No messages."));
+    }
+
+    #[test]
+    fn error_state_renders_error() {
+        assert!(render(app_error).contains("async-error"));
+    }
+
+    #[test]
+    fn ready_state_renders_keyed_channels_and_marks_the_selection() {
+        let html = render(app_ready);
+        assert!(html.contains("user_events"));
+        assert!(html.contains("deploy_hooks"));
+        assert!(
+            html.contains("collection active"),
+            "the selected channel must be marked: {html}"
+        );
+    }
+
+    /// The tail is scoped to the selection. A message on another channel must
+    /// not leak into the pane, or picking a channel means nothing.
+    #[test]
+    fn tail_shows_only_the_selected_channels_messages() {
+        let html = render(app_ready);
+        assert_eq!(
+            html.matches("tail-row").count(),
+            1,
+            "only the user_events message belongs in the tail: {html}"
+        );
+    }
+
+    /// A channel with no traffic must say so. The seam returns every channel's
+    /// messages, so the read is non-empty while the filtered tail has zero
+    /// rows; keying the empty flag off the raw read renders a blank pane.
+    fn app_quiet_channel() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::from_value(Some(Ok(sample_channels()))),
+                messages: AsyncState::from_value(Some(Ok(vec![message(
+                    "notify-0",
+                    "user_events",
+                )]))),
+                selected: Some("deploy_hooks".to_string()),
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    #[test]
+    fn quiet_channel_says_no_messages_instead_of_rendering_blank() {
+        let html = render(app_quiet_channel);
+        assert!(html.contains("No messages."), "{html}");
+        assert_eq!(html.matches("tail-row").count(), 0);
+    }
+
+    /// Loading and Error must win over the filtered-empty check, or the
+    /// spinner and the error text get replaced by "No messages."
+    fn app_loading_tail() -> Element {
+        rsx! {
+            NotifyPanes {
+                channels: AsyncState::from_value(Some(Ok(sample_channels()))),
+                messages: AsyncState::Loading,
+                selected: Some("user_events".to_string()),
+                on_pick: move |_| {},
+                on_retry_channels: move |_| {},
+                on_retry_messages: move |_| {},
+            }
+        }
+    }
+
+    #[test]
+    fn loading_tail_shows_the_spinner_not_no_messages() {
+        let html = render(app_loading_tail);
+        assert!(html.contains("async-loading"), "{html}");
+        assert!(!html.contains("No messages."), "{html}");
+    }
+
+    /// The header must not claim a count while the list is still loading.
+    #[test]
+    fn header_omits_the_count_until_channels_load() {
+        let html = render(app_loading);
+        assert!(html.contains("Channels"), "{html}");
+        assert!(!html.contains("Channels (0)"), "{html}");
+    }
+
+    /// The toolbar's listener count comes from the loaded channel list, so it
+    /// cannot disagree with the number rendered beside the same channel in the
+    /// sidebar.
+    #[test]
+    fn listener_count_comes_from_the_loaded_channel() {
+        let html = render(app_ready);
+        assert!(html.contains("12 listeners"), "{html}");
+    }
+}
diff --git a/nodedb-studio/tests/seam_discipline.rs b/nodedb-studio/tests/seam_discipline.rs
new file mode 100644
index 0000000..371be9a
--- /dev/null
+++ b/nodedb-studio/tests/seam_discipline.rs
@@ -0,0 +1,223 @@
+//! Structural gate: views/components/modals must read data through the
+//! backend seam (`services::backend::Backend`), never straight from
+//! `data::mock`. Reaching past the seam is exactly the bug the seam exists
+//! to prevent — a screen that "works" against mock data but silently breaks
+//! (or never connects) once a real `Backend` impl lands.
+//!
+//! This is a plain filesystem/text scan, not a `syn`-based check: the crate
+//! has no lib target (bin-only, see AGENTS.md), so an integration test here
+//! cannot `use` crate items at all. Scanning source text is the only option
+//! available at this layer, and it is enough to catch the pattern we care
+//! about (`data::mock` / `mock::` reference-by-name).
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+/// Directories (relative to the crate root) that must stay seam-only.
+const SCANNED_ROOTS: &[&str] = &["src/views", "src/components", "src/modals"];
+
+/// No exceptions. `views/streams/notify.rs` was the last one and is now
+/// seam-backed. Anything added back here needs a reason that survives review.
+const ALLOWED_EXCEPTIONS: &[&str] = &[];
+
+/// Recursively collect every `.rs` file under `dir`.
+fn collect_rs_files(dir: &Path, out: &mut Vec) {
+    let Ok(entries) = fs::read_dir(dir) else {
+        return;
+    };
+    for entry in entries.flatten() {
+        let path = entry.path();
+        if path.is_dir() {
+            collect_rs_files(&path, out);
+        } else if path.extension().is_some_and(|ext| ext == "rs") {
+            out.push(path);
+        }
+    }
+}
+
+/// Strip a trailing `//` line comment (if any) so matches inside comments do
+/// not count as violations. This is a simple substring split, not a real
+/// tokenizer — sufficient here because none of the scanned files put `//`
+/// inside a string literal ahead of real code on the same line.
+fn code_part(line: &str) -> &str {
+    match line.find("//") {
+        Some(idx) => &line[..idx],
+        None => line,
+    }
+}
+
+fn references_mock(line: &str) -> bool {
+    let code = code_part(line);
+    code.contains("data::mock") || code.contains("mock::")
+}
+
+#[test]
+fn views_components_and_modals_read_only_through_the_seam() {
+    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+
+    let mut files = Vec::new();
+    for root in SCANNED_ROOTS {
+        collect_rs_files(&manifest_dir.join(root), &mut files);
+    }
+    assert!(
+        !files.is_empty(),
+        "expected to find .rs files under {SCANNED_ROOTS:?} — scan roots may be wrong"
+    );
+
+    let src_dir = manifest_dir.join("src");
+    let mut violations: Vec = Vec::new();
+
+    for file in &files {
+        let rel = file
+            .strip_prefix(&src_dir)
+            .unwrap_or(file)
+            .to_string_lossy()
+            .replace('\\', "/");
+
+        if ALLOWED_EXCEPTIONS.contains(&rel.as_str()) {
+            continue;
+        }
+
+        let Ok(contents) = fs::read_to_string(file) else {
+            continue;
+        };
+        for (idx, line) in contents.lines().enumerate() {
+            if references_mock(line) {
+                violations.push(format!("{rel}:{} : {}", idx + 1, line.trim()));
+            }
+        }
+    }
+
+    assert!(
+        violations.is_empty(),
+        "found {} reference(s) to data::mock outside the allowed exception \
+         ({:?}) — views/components/modals must read through `services::backend::Backend`, \
+         not `data::mock` directly:\n{}",
+        violations.len(),
+        ALLOWED_EXCEPTIONS,
+        violations.join("\n")
+    );
+}
+
+/// Every `connect()` call site must reconcile its result through
+/// `apply_connect`, which hands the error back for rendering.
+///
+/// This exists because the previous fix looked complete and was not: the call
+/// sites were changed from `if let Ok(..)` (drop the error) to
+/// `tracing::error!` (log the error), which leaves the user-facing symptom
+/// identical — a Connect button that does nothing, with no message and no
+/// state change. A count comparison catches a fourth call site added later
+/// that forgets to surface its failure.
+#[test]
+fn every_connect_call_site_reconciles_through_apply_connect() {
+    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+
+    let mut files = Vec::new();
+    for root in SCANNED_ROOTS {
+        collect_rs_files(&manifest_dir.join(root), &mut files);
+    }
+
+    let src_dir = manifest_dir.join("src");
+    let mut total_calls = 0usize;
+    let mut violations: Vec = Vec::new();
+
+    // Compared PER FILE, not as a global total: a single tally lets a file that
+    // drops its result pass because another file happens to contribute a spare
+    // apply_connect line.
+    for file in &files {
+        let rel = file
+            .strip_prefix(&src_dir)
+            .unwrap_or(file)
+            .to_string_lossy()
+            .replace('\\', "/");
+        let Ok(contents) = fs::read_to_string(file) else {
+            continue;
+        };
+        let mut calls = 0usize;
+        let mut reconciles = 0usize;
+        for line in contents.lines() {
+            let code = code_part(line);
+            if code.contains(".connect(") {
+                calls += 1;
+            }
+            if code.contains("apply_connect(") {
+                reconciles += 1;
+            }
+        }
+        total_calls += calls;
+        if calls != reconciles {
+            violations.push(format!(
+                "{rel}: {calls} connect() call site(s), {reconciles} apply_connect() reconcile(s)"
+            ));
+        }
+    }
+
+    assert!(
+        total_calls > 0,
+        "expected at least one connect() call site under {SCANNED_ROOTS:?} — scan may be wrong"
+    );
+    assert!(
+        violations.is_empty(),
+        "a connect result is being dropped or only logged, which renders as a button \
+         that silently does nothing:\n{}",
+        violations.join("\n")
+    );
+}
+
+/// No scope-bound `spawn` in views, components or modals.
+///
+/// Dioxus drops a scope's tasks when the scope is removed, and most of this
+/// tree is conditionally mounted: every popover, every modal, and the
+/// Connection Manager itself, which is swapped for the studio shell the moment
+/// a connect succeeds. A handler that spawns a seam call and then closes its
+/// own popover kills the task at its first await. Nothing renders, nothing
+/// errors, and the button reads as broken.
+///
+/// The mock cannot catch this. `apply_one` resolves on the first poll, so the
+/// task finishes before the unmount can cancel it; only a backend that really
+/// yields reaches the await. That is why this is a source rule rather than a
+/// runtime test.
+///
+/// Seam writes use `spawn_forever` (root scope). Seam reads use `use_resource`
+/// or `use_future`, which are tied to the component on purpose.
+#[test]
+fn views_components_and_modals_never_use_scope_bound_spawn() {
+    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
+
+    let mut files = Vec::new();
+    for root in SCANNED_ROOTS {
+        collect_rs_files(&manifest_dir.join(root), &mut files);
+    }
+
+    let src_dir = manifest_dir.join("src");
+    let mut violations: Vec = Vec::new();
+
+    for file in &files {
+        let rel = file
+            .strip_prefix(&src_dir)
+            .unwrap_or(file)
+            .to_string_lossy()
+            .replace('\\', "/");
+        let Ok(contents) = fs::read_to_string(file) else {
+            continue;
+        };
+        for (idx, line) in contents.lines().enumerate() {
+            let code = code_part(line);
+            // `spawn_forever(` contains `spawn(`-adjacent text, so match the
+            // bare call specifically.
+            if code.contains("spawn(") && !code.contains("spawn_forever(") {
+                violations.push(format!("{rel}:{} : {}", idx + 1, line.trim()));
+            }
+        }
+    }
+
+    assert!(
+        violations.is_empty(),
+        "found {} scope-bound spawn(s). A conditionally-mounted component that \
+         spawns a seam call and then unmounts loses the task at its first await, \
+         so the action silently does nothing. Use spawn_forever for writes, or \
+         use_resource / use_future for reads:\n{}",
+        violations.len(),
+        violations.join("\n")
+    );
+}