From f63c677ca2c2f7a5a9d7cfce38d9ff8f8c858688 Mon Sep 17 00:00:00 2001 From: Francesco Carucci Date: Thu, 19 Mar 2026 09:36:31 -0700 Subject: [PATCH 1/2] Build dashboard summary cards for T5.5 --- src/pages/dashboard.rs | 217 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/src/pages/dashboard.rs b/src/pages/dashboard.rs index 08c447c..9fca475 100644 --- a/src/pages/dashboard.rs +++ b/src/pages/dashboard.rs @@ -4,11 +4,20 @@ use dioxus::prelude::*; use crate::client::use_app_client; use crate::components::graph_canvas::GraphCanvas; +use crate::graph_service::{GraphAssemblySummary, summarize_graph_snapshot}; use crate::models::{ gateway::{GatewayLevel, GatewayStatusSnapshot}, graph::AgentGraphSnapshot, }; +#[derive(Clone, Debug, PartialEq)] +struct SummaryCardModel { + title: &'static str, + value: String, + detail: String, + accent_class: &'static str, +} + #[component] pub fn Dashboard() -> Element { let client = use_app_client(); @@ -32,6 +41,7 @@ pub fn Dashboard() -> Element { "This initial shell gives us a typed routing foundation, a shared layout, and room for the first adapter-backed dashboard queries." } } + DashboardSummaryRow { gateway_status: gateway_status.clone(), graph_snapshot: graph_snapshot.clone() } div { class: "grid grid-cols-1 gap-4 xl:grid-cols-[minmax(19rem,0.85fr)_minmax(0,1.15fr)_minmax(0,1.15fr)]", div { class: "flex flex-col gap-4 xl:col-span-1", article { class: "rounded-[1.6rem] border border-white/10 bg-white/6 p-6 shadow-[0_24px_64px_rgba(2,6,23,0.35)] backdrop-blur-xl", @@ -52,6 +62,38 @@ pub fn Dashboard() -> Element { } } +#[component] +fn DashboardSummaryRow( + gateway_status: Resource>, + graph_snapshot: Resource>, +) -> Element { + let cards = build_summary_cards( + gateway_status.read_unchecked().as_ref(), + graph_snapshot.read_unchecked().as_ref(), + ); + + rsx! { + div { class: "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4", + for card in cards { + SummaryCard { card } + } + } + } +} + +#[component] +fn SummaryCard(card: SummaryCardModel) -> Element { + rsx! { + article { + class: "rounded-[1.45rem] border border-white/10 bg-white/6 p-5 shadow-[0_20px_52px_rgba(2,6,23,0.28)] backdrop-blur-xl", + "data-summary-card": card.title, + p { class: "m-0 text-[0.68rem] font-semibold uppercase tracking-[0.22em] text-slate-400", "{card.title}" } + p { class: format!("m-0 mt-4 text-3xl font-semibold tracking-[-0.05em] {}", card.accent_class), "{card.value}" } + p { class: "m-0 mt-2 text-sm leading-6 text-slate-300", "{card.detail}" } + } + } +} + #[component] fn GraphSnapshotCard( graph_snapshot: Resource>, @@ -135,3 +177,178 @@ fn GatewayStatusCard( }, } } + +fn build_summary_cards( + gateway_status: Option<&Result>, + graph_snapshot: Option<&Result>, +) -> [SummaryCardModel; 4] { + let gateway = gateway_summary_card(gateway_status); + let graph_summary = graph_summary_model(graph_snapshot); + + [ + gateway, + SummaryCardModel { + title: "Agents", + value: graph_summary.agent_count.to_string(), + detail: "Known nodes in the assembled snapshot.".to_string(), + accent_class: "text-white", + }, + SummaryCardModel { + title: "Active agents", + value: graph_summary.active_agent_count.to_string(), + detail: "Nodes currently marked active by session state.".to_string(), + accent_class: "text-emerald-200", + }, + SummaryCardModel { + title: "Connections", + value: graph_summary.edge_count.to_string(), + detail: "Rendered relationships across routes and hints.".to_string(), + accent_class: "text-sky-200", + }, + ] +} + +fn gateway_summary_card( + gateway_status: Option<&Result>, +) -> SummaryCardModel { + match gateway_status { + Some(Ok(snapshot)) => SummaryCardModel { + title: "Gateway", + value: match snapshot.level { + GatewayLevel::Healthy => "Healthy".to_string(), + GatewayLevel::Degraded => "Degraded".to_string(), + }, + detail: snapshot.summary.clone(), + accent_class: match snapshot.level { + GatewayLevel::Healthy => "text-emerald-200", + GatewayLevel::Degraded => "text-amber-300", + }, + }, + Some(Err(error)) => SummaryCardModel { + title: "Gateway", + value: "Unavailable".to_string(), + detail: format!("Gateway lookup failed: {error}"), + accent_class: "text-amber-300", + }, + None => SummaryCardModel { + title: "Gateway", + value: "Loading".to_string(), + detail: "Fetching the latest gateway health snapshot.".to_string(), + accent_class: "text-slate-200", + }, + } +} + +fn graph_summary_model( + graph_snapshot: Option<&Result>, +) -> GraphAssemblySummary { + match graph_snapshot { + Some(Ok(snapshot)) => summarize_graph_snapshot(snapshot), + Some(Err(_)) | None => GraphAssemblySummary::default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::graph::{AgentEdge, AgentEdgeKind, AgentNode, AgentStatus}; + + #[component] + fn SummaryRowHarness( + gateway_status: Option>, + graph_snapshot: Option>, + ) -> Element { + let cards = build_summary_cards(gateway_status.as_ref(), graph_snapshot.as_ref()); + + rsx! { + div { + for card in cards { + SummaryCard { card } + } + } + } + } + + fn render_summary_row( + gateway_status: Option>, + graph_snapshot: Option>, + ) -> String { + let mut dom = VirtualDom::new_with_props( + SummaryRowHarness, + SummaryRowHarnessProps { + gateway_status, + graph_snapshot, + }, + ); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + fn graph_node(id: &str, status: AgentStatus) -> AgentNode { + AgentNode { + id: id.to_string(), + name: id.to_string(), + is_default: id == "main", + heartbeat_enabled: true, + heartbeat_schedule: "every 5m".to_string(), + active_session_count: if status == AgentStatus::Active { 1 } else { 0 }, + latest_activity_age_ms: Some(45_000), + status, + } + } + + #[test] + fn summary_values_match_the_snapshot_fixture() { + let html = render_summary_row( + Some(Ok(GatewayStatusSnapshot { + connected: true, + level: GatewayLevel::Healthy, + summary: "Gateway connected".to_string(), + detail: "healthy detail".to_string(), + gateway_url: "ws://127.0.0.1:18789/".to_string(), + protocol_version: Some(1), + state_version: Some(7), + uptime_ms: Some(12_000), + })), + Some(Ok(AgentGraphSnapshot { + nodes: vec![ + graph_node("main", AgentStatus::Active), + graph_node("email", AgentStatus::Idle), + ], + edges: vec![AgentEdge { + source_id: "main".to_string(), + target_id: "email".to_string(), + kind: AgentEdgeKind::RoutesTo, + }], + snapshot_ts: 1, + })), + ); + + assert!(html.contains("data-summary-card=\"Gateway\"")); + assert!(html.contains(">Healthy<")); + assert!(html.contains("data-summary-card=\"Agents\"")); + assert!(html.contains(">2<")); + assert!(html.contains("data-summary-card=\"Active agents\"")); + assert!(html.contains(">1<")); + assert!(html.contains("data-summary-card=\"Connections\"")); + assert!(html.contains("Known nodes in the assembled snapshot.")); + assert!(html.contains("Rendered relationships across routes and hints.")); + } + + #[test] + fn degraded_gateway_state_is_reflected_in_the_status_summary_card() { + let html = render_summary_row( + Some(Ok(GatewayStatusSnapshot::degraded( + "ws://127.0.0.1:18789/".to_string(), + "Gateway degraded", + "detail", + ))), + None, + ); + + assert!(html.contains("data-summary-card=\"Gateway\"")); + assert!(html.contains(">Degraded<")); + assert!(html.contains("Gateway degraded")); + assert!(html.contains("text-amber-300")); + } +} From 4cf1b4a4b60d4558e8317b585550d340293e9b52 Mon Sep 17 00:00:00 2001 From: Francesco Carucci Date: Fri, 20 Mar 2026 09:10:38 -0700 Subject: [PATCH 2/2] Refine T5.5 dashboard summary layout --- src/pages/agents.rs | 30 ---------- src/pages/dashboard.rs | 123 ++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 86 deletions(-) diff --git a/src/pages/agents.rs b/src/pages/agents.rs index 71ea396..37acc64 100644 --- a/src/pages/agents.rs +++ b/src/pages/agents.rs @@ -42,23 +42,6 @@ fn AgentOverviewSection( ) -> Element { match &*agent_overview.read_unchecked() { Some(Ok(snapshot)) => rsx! { - div { class: "grid grid-cols-1 gap-4 xl:grid-cols-3", - MetricCard { - label: "Configured agents", - value: snapshot.total_agents.to_string(), - detail: "".to_string(), - } - MetricCard { - label: "Active sessions", - value: snapshot.total_active_sessions.to_string(), - detail: "".to_string(), - } - MetricCard { - label: "Recently active", - value: snapshot.active_recent_agents.to_string(), - detail: "".to_string(), - } - } div { class: "mt-2 flex items-center gap-3", p { class: "m-0 text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-slate-400", "Agent tiles" } p { class: "m-0 text-sm text-slate-400", "{snapshot.active_recent_agents}/{snapshot.total_agents} active in the last 10 minutes" } @@ -92,19 +75,6 @@ fn AgentOverviewSection( } } -#[component] -fn MetricCard(label: String, value: String, detail: String) -> Element { - rsx! { - article { class: "rounded-[1.6rem] border border-white/10 bg-white/6 p-6 shadow-[0_24px_64px_rgba(2,6,23,0.35)] backdrop-blur-xl", - p { class: "m-0 text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[var(--signal)]", "{label}" } - p { class: "m-0 mt-3 text-3xl font-semibold tracking-[-0.05em] text-white", "{value}" } - if !detail.is_empty() { - p { class: "m-0 mt-3 text-sm leading-6 text-slate-300", "{detail}" } - } - } - } -} - #[component] fn AgentCard(agent: AgentOverviewItem) -> Element { let elapsed_ms = use_signal(|| 0_u64); diff --git a/src/pages/dashboard.rs b/src/pages/dashboard.rs index 9fca475..5c6dcb8 100644 --- a/src/pages/dashboard.rs +++ b/src/pages/dashboard.rs @@ -10,6 +10,9 @@ use crate::models::{ graph::AgentGraphSnapshot, }; +#[cfg(test)] +use crate::models::graph::{AgentNode, AgentStatus}; + #[derive(Clone, Debug, PartialEq)] struct SummaryCardModel { title: &'static str, @@ -71,11 +74,22 @@ fn DashboardSummaryRow( gateway_status.read_unchecked().as_ref(), graph_snapshot.read_unchecked().as_ref(), ); + let [ + gateway_card, + agents_card, + active_agents_card, + connections_card, + ] = cards; rsx! { - div { class: "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4", - for card in cards { - SummaryCard { card } + div { class: "grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(19rem,0.85fr)_minmax(0,1.15fr)_minmax(0,1.15fr)]", + div { class: "xl:col-span-1", + SummaryCard { card: gateway_card } + } + div { class: "min-w-0 grid items-start gap-4 sm:grid-cols-2 xl:col-span-2 xl:grid-cols-3", + SummaryCard { card: agents_card } + SummaryCard { card: active_agents_card } + SummaryCard { card: connections_card } } } } @@ -85,7 +99,7 @@ fn DashboardSummaryRow( fn SummaryCard(card: SummaryCardModel) -> Element { rsx! { article { - class: "rounded-[1.45rem] border border-white/10 bg-white/6 p-5 shadow-[0_20px_52px_rgba(2,6,23,0.28)] backdrop-blur-xl", + class: "min-w-0 h-[14rem] rounded-[1.6rem] border border-white/10 bg-white/6 p-6 shadow-[0_24px_64px_rgba(2,6,23,0.35)] backdrop-blur-xl", "data-summary-card": card.title, p { class: "m-0 text-[0.68rem] font-semibold uppercase tracking-[0.22em] text-slate-400", "{card.title}" } p { class: format!("m-0 mt-4 text-3xl font-semibold tracking-[-0.05em] {}", card.accent_class), "{card.value}" } @@ -178,36 +192,6 @@ fn GatewayStatusCard( } } -fn build_summary_cards( - gateway_status: Option<&Result>, - graph_snapshot: Option<&Result>, -) -> [SummaryCardModel; 4] { - let gateway = gateway_summary_card(gateway_status); - let graph_summary = graph_summary_model(graph_snapshot); - - [ - gateway, - SummaryCardModel { - title: "Agents", - value: graph_summary.agent_count.to_string(), - detail: "Known nodes in the assembled snapshot.".to_string(), - accent_class: "text-white", - }, - SummaryCardModel { - title: "Active agents", - value: graph_summary.active_agent_count.to_string(), - detail: "Nodes currently marked active by session state.".to_string(), - accent_class: "text-emerald-200", - }, - SummaryCardModel { - title: "Connections", - value: graph_summary.edge_count.to_string(), - detail: "Rendered relationships across routes and hints.".to_string(), - accent_class: "text-sky-200", - }, - ] -} - fn gateway_summary_card( gateway_status: Option<&Result>, ) -> SummaryCardModel { @@ -239,6 +223,36 @@ fn gateway_summary_card( } } +fn build_summary_cards( + gateway_status: Option<&Result>, + graph_snapshot: Option<&Result>, +) -> [SummaryCardModel; 4] { + let gateway = gateway_summary_card(gateway_status); + let graph_summary = graph_summary_model(graph_snapshot); + + [ + gateway, + SummaryCardModel { + title: "Agents", + value: graph_summary.agent_count.to_string(), + detail: "Known nodes in the assembled snapshot.".to_string(), + accent_class: "text-sky-200", + }, + SummaryCardModel { + title: "Active agents", + value: graph_summary.active_agent_count.to_string(), + detail: "Nodes currently marked active by session state.".to_string(), + accent_class: "text-emerald-200", + }, + SummaryCardModel { + title: "Connections", + value: graph_summary.edge_count.to_string(), + detail: "Rendered relationships across routes and hints.".to_string(), + accent_class: "text-violet-200", + }, + ] +} + fn graph_summary_model( graph_snapshot: Option<&Result>, ) -> GraphAssemblySummary { @@ -251,7 +265,6 @@ fn graph_summary_model( #[cfg(test)] mod tests { use super::*; - use crate::models::graph::{AgentEdge, AgentEdgeKind, AgentNode, AgentStatus}; #[component] fn SummaryRowHarness( @@ -284,19 +297,6 @@ mod tests { dioxus_ssr::render(&dom) } - fn graph_node(id: &str, status: AgentStatus) -> AgentNode { - AgentNode { - id: id.to_string(), - name: id.to_string(), - is_default: id == "main", - heartbeat_enabled: true, - heartbeat_schedule: "every 5m".to_string(), - active_session_count: if status == AgentStatus::Active { 1 } else { 0 }, - latest_activity_age_ms: Some(45_000), - status, - } - } - #[test] fn summary_values_match_the_snapshot_fixture() { let html = render_summary_row( @@ -312,13 +312,13 @@ mod tests { })), Some(Ok(AgentGraphSnapshot { nodes: vec![ - graph_node("main", AgentStatus::Active), - graph_node("email", AgentStatus::Idle), + graph_node("calendar", AgentStatus::Active), + graph_node("planner", AgentStatus::Idle), ], - edges: vec![AgentEdge { - source_id: "main".to_string(), - target_id: "email".to_string(), - kind: AgentEdgeKind::RoutesTo, + edges: vec![crate::models::graph::AgentEdge { + source_id: "calendar".to_string(), + target_id: "planner".to_string(), + kind: crate::models::graph::AgentEdgeKind::RoutesTo, }], snapshot_ts: 1, })), @@ -331,8 +331,6 @@ mod tests { assert!(html.contains("data-summary-card=\"Active agents\"")); assert!(html.contains(">1<")); assert!(html.contains("data-summary-card=\"Connections\"")); - assert!(html.contains("Known nodes in the assembled snapshot.")); - assert!(html.contains("Rendered relationships across routes and hints.")); } #[test] @@ -351,4 +349,17 @@ mod tests { assert!(html.contains("Gateway degraded")); assert!(html.contains("text-amber-300")); } + + fn graph_node(id: &str, status: AgentStatus) -> AgentNode { + AgentNode { + id: id.to_string(), + name: id.to_string(), + is_default: false, + heartbeat_enabled: true, + heartbeat_schedule: "*/5 * * * *".to_string(), + active_session_count: matches!(status, AgentStatus::Active) as u64, + latest_activity_age_ms: Some(60_000), + status, + } + } }