Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
pull_request:

jobs:
check:
name: fmt + clippy + tests (macOS)
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Cache cargo
uses: Swatinem/rust-cache@v2

- name: Install nextest
uses: taiki-e/install-action@nextest

- name: Format check
run: cargo fmt --all --check

- name: Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

- name: Tests
run: cargo nextest run --workspace
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ nodedb-client = "0.3.0"
nodedb-types = "0.3.0"
# Async
async-trait = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
# UI. The `router` feature re-exports the router through dioxus::prelude.
# Desktop-only for the skeleton.
dioxus = { version = "0.7", features = ["desktop", "router"] }
Expand Down
7 changes: 4 additions & 3 deletions nodedb-studio/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! - Connected -> `Studio`
//!
//! Global state is exposed as fine-grained signals via context. The backend
//! seam is a `dyn ConnectionService` behind an `Rc`, so swapping the mock for a
//! seam is a `dyn Backend` behind an `Rc`, so swapping the mock for a
//! real NodeDB client later is a one-line change here.

use std::rc::Rc;
Expand All @@ -15,7 +15,8 @@ use dioxus::prelude::*;
use crate::modals::ModalHost;
use crate::models::notification::Notification;
use crate::services::async_state::AsyncState;
use crate::services::connection_service::{ConnectionService, MockConnectionService};
use crate::services::backend::Backend;
use crate::services::connection_service::MockConnectionService;
use crate::state::connection::ActiveConnection;
use crate::state::connections_registry::SavedConnection;
use crate::state::preferences::Preferences;
Expand All @@ -30,7 +31,7 @@ const STYLES: Asset = asset!("/assets/styles.css");
pub fn App() -> Element {
// The single seam to the outside world. Provided as a trait object so a
// real client can replace the mock without touching consumers.
let service: Rc<dyn ConnectionService> = Rc::new(MockConnectionService);
let service: Rc<dyn Backend> = Rc::new(MockConnectionService::ready());
// The real-client stub's instantiability + object-safety behind the seam is
// proven by `nodedb_service::tests::stub_is_object_safe_behind_rc`, so it is
// not constructed here on every render.
Expand Down
4 changes: 2 additions & 2 deletions nodedb-studio/src/components/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use dioxus::prelude::*;

use crate::routes::Route;
use crate::services::connection_service::ConnectionService;
use crate::services::backend::Backend;
use crate::state::connection::ActiveConnection;
use crate::state::ui::ModalKind;

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

if !*open.read() {
Expand Down
57 changes: 57 additions & 0 deletions nodedb-studio/src/components/live_tail.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Reusable live-tail layout shell (frame + toolbar/body/footer slots). Owns the
//! `.live-tail` structure + CSS so every live screen (CDC, Notify, MV, Console)
//! composes the same chrome instead of re-emitting it. Screen-specific content
//! (rows, status pills, footer stats) is passed in as slot Elements.

use dioxus::prelude::*;

#[derive(Clone, PartialEq, Props)]
pub struct LiveTailProps {
/// Toolbar content (title, status pill, filter, Pause/Export buttons).
pub toolbar: Element,
/// Scrolling body (the rows).
pub body: Element,
/// Footer content (buffer/lag stats).
pub footer: Element,
}

#[component]
pub fn LiveTail(props: LiveTailProps) -> Element {
rsx! {
div { class: "live-tail",
div { class: "tail-toolbar", {props.toolbar} }
div { class: "tail-body", {props.body} }
div { class: "tail-footer", {props.footer} }
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn render(app: fn() -> Element) -> String {
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
dioxus_ssr::render(&dom)
}

#[test]
fn renders_three_slots_in_frame() {
fn app() -> Element {
rsx! {
LiveTail {
toolbar: rsx! { span { "TB" } },
body: rsx! { div { "ROWS" } },
footer: rsx! { span { "FT" } },
}
}
}
let html = render(app);
assert!(html.contains("live-tail"));
assert!(html.contains("tail-toolbar"));
assert!(html.contains("tail-body"));
assert!(html.contains("tail-footer"));
assert!(html.contains("TB") && html.contains("ROWS") && html.contains("FT"));
}
}
2 changes: 2 additions & 0 deletions nodedb-studio/src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

pub mod async_view;
pub mod command_palette;
pub mod live_tail;
pub mod modal;
pub mod popovers;
pub mod rail;
pub mod snav;
pub mod sparkline;
pub mod statusbar;
pub mod subnav;
pub mod topbar;
4 changes: 2 additions & 2 deletions nodedb-studio/src/components/popovers/connection_popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::rc::Rc;

use dioxus::prelude::*;

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

let conn = active.read();
let Some(c) = conn.as_ref() else {
Expand Down
20 changes: 19 additions & 1 deletion nodedb-studio/src/components/popovers/notification_popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
//! and the list never diverge. The Error-state Retry reloads the feed via the
//! shared `Resource` handle, gated on `StudioError::is_retriable()`.

use std::rc::Rc;

use dioxus::prelude::*;

use crate::components::async_view::AsyncView;
use crate::models::notification::{Notification, NotificationTarget};
use crate::routes::Route;
use crate::services::async_state::AsyncState;
use crate::services::backend::Backend;
use crate::state::connection::{ActiveConnection, Capabilities};
use crate::state::notifications::{mark_all_read, mark_read, visible};
use crate::state::ui::Popover;
Expand Down Expand Up @@ -45,6 +48,7 @@ pub fn NotificationPopover() -> Element {
let mut reload = use_context::<Resource<()>>();
let mut popover = use_context::<Signal<Option<Popover>>>();
let active = use_context::<Signal<Option<ActiveConnection>>>();
let backend = use_context::<Rc<dyn Backend>>();
let nav = use_navigator();

// Capability gate (unchanged): no connection -> render nothing.
Expand Down Expand Up @@ -86,10 +90,24 @@ pub fn NotificationPopover() -> Element {
h4 { "Notifications " span { class: "count", "{count_label}" } }
button {
onclick: move |_| {
// Mutate the shared store; the badge re-derives from it.
// 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}"),
}
});
},
"Mark all read"
}
Expand Down
75 changes: 75 additions & 0 deletions nodedb-studio/src/components/sparkline.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! SVG-fidelity spike: a minimal sparkline drawn as an inline `<svg><polyline>`
//! in pure Dioxus (no JS interop), fed from a typed `Vec<f64>`. Proves whether
//! rsx!-driven SVG gives the fidelity later chart/topology screens need.

use dioxus::prelude::*;

#[derive(Clone, PartialEq, Props)]
pub struct SparklineProps {
/// Y values; X is the index. Normalized to a 100x24 viewBox.
pub points: Vec<f64>,
}

#[component]
pub fn Sparkline(props: SparklineProps) -> Element {
let pts = &props.points;
if pts.len() < 2 {
return rsx! { svg { width: "100", height: "24", "viewBox": "0 0 100 24" } };
}
let max = pts.iter().cloned().fold(f64::MIN, f64::max);
let min = pts.iter().cloned().fold(f64::MAX, f64::min);
let span = (max - min).max(f64::EPSILON);
let n = (pts.len() - 1) as f64;
let coords: String = pts
.iter()
.enumerate()
.map(|(i, y)| {
let x = (i as f64 / n) * 100.0;
let yy = 24.0 - ((y - min) / span) * 24.0;
format!("{x:.1},{yy:.1}")
})
.collect::<Vec<_>>()
.join(" ");
rsx! {
svg { width: "100", height: "24", "viewBox": "0 0 100 24",
polyline {
points: "{coords}",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.5",
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn render(app: fn() -> Element) -> String {
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
dioxus_ssr::render(&dom)
}

#[test]
fn renders_polyline_from_points() {
fn app() -> Element {
rsx! { Sparkline { points: vec![1.0, 4.0, 2.0, 8.0, 5.0] } }
}
let html = render(app);
assert!(html.contains("<svg"));
assert!(html.contains("<polyline"));
assert!(html.contains("points="));
}

#[test]
fn degenerate_input_renders_empty_svg() {
fn app() -> Element {
rsx! { Sparkline { points: vec![1.0] } }
}
let html = render(app);
assert!(html.contains("<svg"));
assert!(!html.contains("<polyline"));
}
}
Loading
Loading