Skip to content

Commit 4964d21

Browse files
authored
Merge pull request #3 from NodeDB-Lab/feat/foundation-sc
feat(foundation): backend seam (reads+writes), typed models, four-state mock, macOS CI, CDC tracer
2 parents e3c56fd + ff9ad2f commit 4964d21

26 files changed

Lines changed: 1267 additions & 583 deletions

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
check:
9+
name: fmt + clippy + tests (macOS)
10+
runs-on: macos-latest
11+
timeout-minutes: 30
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install Rust toolchain
16+
uses: dtolnay/rust-toolchain@stable
17+
with:
18+
components: rustfmt, clippy
19+
20+
- name: Cache cargo
21+
uses: Swatinem/rust-cache@v2
22+
23+
- name: Install nextest
24+
uses: taiki-e/install-action@nextest
25+
26+
- name: Format check
27+
run: cargo fmt --all --check
28+
29+
- name: Clippy
30+
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
31+
32+
- name: Tests
33+
run: cargo nextest run --workspace

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ nodedb-client = "0.3.0"
2121
nodedb-types = "0.3.0"
2222
# Async
2323
async-trait = "0.1"
24-
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
24+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
2525
# UI. The `router` feature re-exports the router through dioxus::prelude.
2626
# Desktop-only for the skeleton.
2727
dioxus = { version = "0.7", features = ["desktop", "router"] }

nodedb-studio/src/app.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! - Connected -> `Studio`
66
//!
77
//! Global state is exposed as fine-grained signals via context. The backend
8-
//! seam is a `dyn ConnectionService` behind an `Rc`, so swapping the mock for a
8+
//! seam is a `dyn Backend` behind an `Rc`, so swapping the mock for a
99
//! real NodeDB client later is a one-line change here.
1010
1111
use std::rc::Rc;
@@ -15,7 +15,8 @@ use dioxus::prelude::*;
1515
use crate::modals::ModalHost;
1616
use crate::models::notification::Notification;
1717
use crate::services::async_state::AsyncState;
18-
use crate::services::connection_service::{ConnectionService, MockConnectionService};
18+
use crate::services::backend::Backend;
19+
use crate::services::connection_service::MockConnectionService;
1920
use crate::state::connection::ActiveConnection;
2021
use crate::state::connections_registry::SavedConnection;
2122
use crate::state::preferences::Preferences;
@@ -30,7 +31,7 @@ const STYLES: Asset = asset!("/assets/styles.css");
3031
pub fn App() -> Element {
3132
// The single seam to the outside world. Provided as a trait object so a
3233
// real client can replace the mock without touching consumers.
33-
let service: Rc<dyn ConnectionService> = Rc::new(MockConnectionService);
34+
let service: Rc<dyn Backend> = Rc::new(MockConnectionService::ready());
3435
// The real-client stub's instantiability + object-safety behind the seam is
3536
// proven by `nodedb_service::tests::stub_is_object_safe_behind_rc`, so it is
3637
// not constructed here on every render.

nodedb-studio/src/components/command_palette.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use dioxus::prelude::*;
77

88
use crate::routes::Route;
9-
use crate::services::connection_service::ConnectionService;
9+
use crate::services::backend::Backend;
1010
use crate::state::connection::ActiveConnection;
1111
use crate::state::ui::ModalKind;
1212

@@ -15,7 +15,7 @@ pub fn CommandPalette() -> Element {
1515
let mut open = use_context::<Signal<bool>>();
1616
let mut active = use_context::<Signal<Option<ActiveConnection>>>();
1717
let mut modal = use_context::<Signal<Option<ModalKind>>>();
18-
let service = use_context::<std::rc::Rc<dyn ConnectionService>>();
18+
let service = use_context::<std::rc::Rc<dyn Backend>>();
1919
let nav = use_navigator();
2020

2121
if !*open.read() {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! Reusable live-tail layout shell (frame + toolbar/body/footer slots). Owns the
2+
//! `.live-tail` structure + CSS so every live screen (CDC, Notify, MV, Console)
3+
//! composes the same chrome instead of re-emitting it. Screen-specific content
4+
//! (rows, status pills, footer stats) is passed in as slot Elements.
5+
6+
use dioxus::prelude::*;
7+
8+
#[derive(Clone, PartialEq, Props)]
9+
pub struct LiveTailProps {
10+
/// Toolbar content (title, status pill, filter, Pause/Export buttons).
11+
pub toolbar: Element,
12+
/// Scrolling body (the rows).
13+
pub body: Element,
14+
/// Footer content (buffer/lag stats).
15+
pub footer: Element,
16+
}
17+
18+
#[component]
19+
pub fn LiveTail(props: LiveTailProps) -> Element {
20+
rsx! {
21+
div { class: "live-tail",
22+
div { class: "tail-toolbar", {props.toolbar} }
23+
div { class: "tail-body", {props.body} }
24+
div { class: "tail-footer", {props.footer} }
25+
}
26+
}
27+
}
28+
29+
#[cfg(test)]
30+
mod tests {
31+
use super::*;
32+
33+
fn render(app: fn() -> Element) -> String {
34+
let mut dom = VirtualDom::new(app);
35+
dom.rebuild_in_place();
36+
dioxus_ssr::render(&dom)
37+
}
38+
39+
#[test]
40+
fn renders_three_slots_in_frame() {
41+
fn app() -> Element {
42+
rsx! {
43+
LiveTail {
44+
toolbar: rsx! { span { "TB" } },
45+
body: rsx! { div { "ROWS" } },
46+
footer: rsx! { span { "FT" } },
47+
}
48+
}
49+
}
50+
let html = render(app);
51+
assert!(html.contains("live-tail"));
52+
assert!(html.contains("tail-toolbar"));
53+
assert!(html.contains("tail-body"));
54+
assert!(html.contains("tail-footer"));
55+
assert!(html.contains("TB") && html.contains("ROWS") && html.contains("FT"));
56+
}
57+
}

nodedb-studio/src/components/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
33
pub mod async_view;
44
pub mod command_palette;
5+
pub mod live_tail;
56
pub mod modal;
67
pub mod popovers;
78
pub mod rail;
89
pub mod snav;
10+
pub mod sparkline;
911
pub mod statusbar;
1012
pub mod subnav;
1113
pub mod topbar;

nodedb-studio/src/components/popovers/connection_popover.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::rc::Rc;
55

66
use dioxus::prelude::*;
77

8-
use crate::services::connection_service::ConnectionService;
8+
use crate::services::backend::Backend;
99
use crate::state::connection::ActiveConnection;
1010
use crate::state::connections_registry::{ConnStatus, SavedConnection};
1111
use crate::state::ui::{ModalKind, Popover};
@@ -16,7 +16,7 @@ pub fn ConnectionPopover() -> Element {
1616
let mut popover = use_context::<Signal<Option<Popover>>>();
1717
let mut modal = use_context::<Signal<Option<ModalKind>>>();
1818
let registry = use_context::<Signal<Vec<SavedConnection>>>();
19-
let service = use_context::<Rc<dyn ConnectionService>>();
19+
let service = use_context::<Rc<dyn Backend>>();
2020

2121
let conn = active.read();
2222
let Some(c) = conn.as_ref() else {

nodedb-studio/src/components/popovers/notification_popover.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
//! and the list never diverge. The Error-state Retry reloads the feed via the
1111
//! shared `Resource` handle, gated on `StudioError::is_retriable()`.
1212
13+
use std::rc::Rc;
14+
1315
use dioxus::prelude::*;
1416

1517
use crate::components::async_view::AsyncView;
1618
use crate::models::notification::{Notification, NotificationTarget};
1719
use crate::routes::Route;
1820
use crate::services::async_state::AsyncState;
21+
use crate::services::backend::Backend;
1922
use crate::state::connection::{ActiveConnection, Capabilities};
2023
use crate::state::notifications::{mark_all_read, mark_read, visible};
2124
use crate::state::ui::Popover;
@@ -45,6 +48,7 @@ pub fn NotificationPopover() -> Element {
4548
let mut reload = use_context::<Resource<()>>();
4649
let mut popover = use_context::<Signal<Option<Popover>>>();
4750
let active = use_context::<Signal<Option<ActiveConnection>>>();
51+
let backend = use_context::<Rc<dyn Backend>>();
4852
let nav = use_navigator();
4953

5054
// Capability gate (unchanged): no connection -> render nothing.
@@ -86,10 +90,24 @@ pub fn NotificationPopover() -> Element {
8690
h4 { "Notifications " span { class: "count", "{count_label}" } }
8791
button {
8892
onclick: move |_| {
89-
// Mutate the shared store; the badge re-derives from it.
93+
// In-memory update first for snappy UI (write guard dropped
94+
// before the block ends, never held across an await).
9095
if let Some(items) = store.write().loaded_mut() {
9196
mark_all_read(items);
9297
}
98+
// Persist through the seam so the badge stays cleared on
99+
// any subsequent reload (fixes POP-03). The spawn avoids
100+
// holding any signal guard across the await.
101+
// On success, reconcile the shared feed so the real client's
102+
// persisted state is reflected (reload.restart() re-fetches).
103+
let backend = backend.clone();
104+
let mut reload = reload;
105+
spawn(async move {
106+
match backend.mark_all_read().await {
107+
Ok(()) => reload.restart(),
108+
Err(e) => tracing::warn!("mark_all_read failed: {e}"),
109+
}
110+
});
93111
},
94112
"Mark all read"
95113
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//! SVG-fidelity spike: a minimal sparkline drawn as an inline `<svg><polyline>`
2+
//! in pure Dioxus (no JS interop), fed from a typed `Vec<f64>`. Proves whether
3+
//! rsx!-driven SVG gives the fidelity later chart/topology screens need.
4+
5+
use dioxus::prelude::*;
6+
7+
#[derive(Clone, PartialEq, Props)]
8+
pub struct SparklineProps {
9+
/// Y values; X is the index. Normalized to a 100x24 viewBox.
10+
pub points: Vec<f64>,
11+
}
12+
13+
#[component]
14+
pub fn Sparkline(props: SparklineProps) -> Element {
15+
let pts = &props.points;
16+
if pts.len() < 2 {
17+
return rsx! { svg { width: "100", height: "24", "viewBox": "0 0 100 24" } };
18+
}
19+
let max = pts.iter().cloned().fold(f64::MIN, f64::max);
20+
let min = pts.iter().cloned().fold(f64::MAX, f64::min);
21+
let span = (max - min).max(f64::EPSILON);
22+
let n = (pts.len() - 1) as f64;
23+
let coords: String = pts
24+
.iter()
25+
.enumerate()
26+
.map(|(i, y)| {
27+
let x = (i as f64 / n) * 100.0;
28+
let yy = 24.0 - ((y - min) / span) * 24.0;
29+
format!("{x:.1},{yy:.1}")
30+
})
31+
.collect::<Vec<_>>()
32+
.join(" ");
33+
rsx! {
34+
svg { width: "100", height: "24", "viewBox": "0 0 100 24",
35+
polyline {
36+
points: "{coords}",
37+
fill: "none",
38+
stroke: "currentColor",
39+
"stroke-width": "1.5",
40+
}
41+
}
42+
}
43+
}
44+
45+
#[cfg(test)]
46+
mod tests {
47+
use super::*;
48+
49+
fn render(app: fn() -> Element) -> String {
50+
let mut dom = VirtualDom::new(app);
51+
dom.rebuild_in_place();
52+
dioxus_ssr::render(&dom)
53+
}
54+
55+
#[test]
56+
fn renders_polyline_from_points() {
57+
fn app() -> Element {
58+
rsx! { Sparkline { points: vec![1.0, 4.0, 2.0, 8.0, 5.0] } }
59+
}
60+
let html = render(app);
61+
assert!(html.contains("<svg"));
62+
assert!(html.contains("<polyline"));
63+
assert!(html.contains("points="));
64+
}
65+
66+
#[test]
67+
fn degenerate_input_renders_empty_svg() {
68+
fn app() -> Element {
69+
rsx! { Sparkline { points: vec![1.0] } }
70+
}
71+
let html = render(app);
72+
assert!(html.contains("<svg"));
73+
assert!(!html.contains("<polyline"));
74+
}
75+
}

0 commit comments

Comments
 (0)