Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c66b7e8
chore: ignore subagent-driven-development scratch directory
laksamanakeris Aug 8, 2026
593c9a8
feat(seam): column-addressed row decoder with column assertion
laksamanakeris Aug 8, 2026
4084a00
fix(decode): clarify short-row error message and add covering test
laksamanakeris Aug 8, 2026
7ebefe7
feat(seam): require an explicit username to connect
laksamanakeris Aug 8, 2026
6a07d43
feat(seam): shared mock behaviour with a delayed variant
laksamanakeris Aug 8, 2026
64b95da
feat(seam): ExplorerData trait, models and fixtures
laksamanakeris Aug 8, 2026
dde4827
test(seam): cover record_detail and assert canonical group order
laksamanakeris Aug 8, 2026
53e9690
feat(seam): AdminData trait, models and fixtures
laksamanakeris Aug 8, 2026
17a1afe
test(seam): cover admin behaviour paths and fixture semantics
laksamanakeris Aug 8, 2026
6dea12f
feat(seam): CDC consumer-group lifecycle plus MV, topics, cron and no…
laksamanakeris Aug 8, 2026
335ebb2
fix(seam): stop CDC cursor advancing on empty or erroring reads
laksamanakeris Aug 8, 2026
5b11ec3
feat(seam): WorkbenchData trait for query, explain and schema tree
laksamanakeris Aug 8, 2026
23f84db
feat(seam): ViewersData trait for graph, vector, series, spatial, FTS…
laksamanakeris Aug 8, 2026
efc3ff4
feat(seam): nav badges, session info and database list
laksamanakeris Aug 8, 2026
ebef86b
feat(seam): wire Explorer sidebar to collection_groups, add seam-disc…
laksamanakeris Aug 8, 2026
622ced4
fix(services): unblock AsyncView for single-value seam reads
laksamanakeris Aug 8, 2026
1f8e031
chore(services): retag decoder dead-code allows as SEAM-UNWIRED
laksamanakeris Aug 8, 2026
478ade5
test: remove or repair three weak seam tests
laksamanakeris Aug 8, 2026
7c60ef9
fix(state): redact Credentials password from Debug output
laksamanakeris Aug 8, 2026
92449d1
fix(data): align nav_badges streams fixture with the rail literal
laksamanakeris Aug 8, 2026
6480ebc
fix(views): derive Explorer's default selection from loaded collections
laksamanakeris Aug 9, 2026
c7cf37a
chore: drop internal task-id references from source and comments
laksamanakeris Aug 9, 2026
4f987bd
fix(ui): surface connect() failures instead of dropping them
laksamanakeris Aug 9, 2026
f877482
fix(data): give admin fixtures id/name pairs distinct like streams does
laksamanakeris Aug 9, 2026
1205088
fix(services): reach AsyncState::Empty for query and graph reads
laksamanakeris Aug 9, 2026
41a1602
docs(comments): correct false statements in two comments
laksamanakeris Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,6 @@ docs/nodedb_lab_studio_mockup_v4.html
specs/*
docs/*
.DS_Store

# Subagent-driven-development scratch (ledger, briefs, review packages)
.superpowers/
36 changes: 34 additions & 2 deletions nodedb-studio/src/components/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use dioxus::prelude::*;
use crate::routes::Route;
use crate::services::backend::Backend;
use crate::state::connection::ActiveConnection;
use crate::state::connections_registry::{Credentials, SavedConnection};
use crate::state::ui::ModalKind;

#[component]
Expand All @@ -16,6 +17,7 @@ pub fn CommandPalette() -> Element {
let mut active = use_context::<Signal<Option<ActiveConnection>>>();
let mut modal = use_context::<Signal<Option<ModalKind>>>();
let service = use_context::<std::rc::Rc<dyn Backend>>();
let registry = use_context::<Signal<Vec<SavedConnection>>>();
let nav = use_navigator();

if !*open.read() {
Expand All @@ -26,6 +28,22 @@ pub fn CommandPalette() -> Element {
// each switch handler clones it.
let switch_svc = service.clone();

// TODO: the palette has no username field yet, so identity is taken from
// the saved profile rather than a user-entered value. Replace once the
// connect modal collects credentials explicitly.
let creds_for = |name: &str| -> Credentials {
Credentials {
username: registry
.peek()
.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",
Expand Down Expand Up @@ -66,10 +84,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();
let creds = creds.clone();
spawn(async move {
if let Ok(s) = svc.connect("staging-cluster").await { active.set(Some(s)); }
match svc.connect("staging-cluster", &creds).await {
Ok(s) => active.set(Some(s)),
Err(e) => tracing::error!(
"connect to staging-cluster failed: {e}"
),
}
});
open.set(false);
}
Expand All @@ -78,10 +103,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();
let creds = creds.clone();
spawn(async move {
if let Ok(s) = svc.connect("prod-replica-eu").await { active.set(Some(s)); }
match svc.connect("prod-replica-eu", &creds).await {
Ok(s) => active.set(Some(s)),
Err(e) => tracing::error!(
"connect to prod-replica-eu failed: {e}"
),
}
});
open.set(false);
}
Expand Down
21 changes: 19 additions & 2 deletions nodedb-studio/src/components/popovers/connection_popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use dioxus::prelude::*;

use crate::services::backend::Backend;
use crate::state::connection::ActiveConnection;
use crate::state::connections_registry::{ConnStatus, SavedConnection};
use crate::state::connections_registry::{ConnStatus, Credentials, SavedConnection};
use crate::state::ui::{ModalKind, Popover};

#[component]
Expand Down Expand Up @@ -50,6 +50,17 @@ pub fn ConnectionPopover() -> Element {
};
let svc = service.clone();
let item_class = if disabled { "cp-item disabled" } else { "cp-item" };
// TODO: the switch popover has no username field yet, so identity is
// taken from the saved profile rather than a user-entered value.
// Replace once the connect modal collects credentials explicitly.
let creds = Credentials {
username: sc
.profile
.as_ref()
.map(|p| p.user.clone())
.unwrap_or_default(),
password: None,
};
rsx! {
div {
class: "{item_class}",
Expand All @@ -59,8 +70,14 @@ pub fn ConnectionPopover() -> Element {
// set `active` (Copy) only after the await resolves.
let svc = svc.clone();
let name = name.clone();
let creds = creds.clone();
spawn(async move {
if let Ok(s) = svc.connect(&name).await { active.set(Some(s)); }
match svc.connect(&name, &creds).await {
Ok(s) => active.set(Some(s)),
Err(e) => {
tracing::error!("connect to {name} failed: {e}")
}
}
});
popover.set(None);
}
Expand Down
143 changes: 143 additions & 0 deletions nodedb-studio/src/data/mock/admin.rs
Original file line number Diff line number Diff line change
@@ -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<ClusterNode> {
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<RaftGroup> {
(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<ShardRange> {
(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<UserRow> {
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<RlsPolicy> {
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<AuditEntry> {
(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::<Vec<_>>());
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::<Vec<_>>());
assert_ids_distinct_from_names(rows.iter().map(|r| (r.id.as_str(), r.name.as_str())));
}
}
96 changes: 69 additions & 27 deletions nodedb-studio/src/data/mock/connections.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -124,32 +124,6 @@ pub fn connections() -> Vec<SavedConnection> {
]
}

/// 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<Collection> {
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<Notification> {
Expand Down Expand Up @@ -222,3 +196,71 @@ pub fn notifications() -> Vec<Notification> {
},
]
}

/// 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<String> {
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
);
}
}
}
}
Loading
Loading