From 494600b43fac98bf6e0f5f1d2e8066b7fa13e8b3 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 17:45:34 +0100 Subject: [PATCH] fix: MCP cards redesign, inline graph, DAST spacing, missing back buttons - Redesign MCP server cards with proper CSS (grid layout, config details, tool chips, token section with icon actions) - Add back button to MCP Servers page and DAST Findings page - Embed graph explorer inline on repositories page (toggle via graph icon) instead of navigating to separate page - Refactor GraphExplorerPage into shared GraphExplorerBody component with GraphExplorerInline variant for embedding - Fix DAST Overview spacing: proper stat cards, button margins, icons - Add btn-active state for toggled graph button Co-Authored-By: Claude Opus 4.6 --- compliance-dashboard/assets/main.css | 202 ++++++++++++++++ .../src/pages/dast_findings.rs | 11 + .../src/pages/dast_overview.rs | 41 ++-- .../src/pages/graph_explorer.rs | 59 ++--- compliance-dashboard/src/pages/mcp_servers.rs | 227 ++++++++++-------- .../src/pages/repositories.rs | 34 ++- 6 files changed, 425 insertions(+), 149 deletions(-) diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 26cd80b..8a8cf52 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -727,6 +727,208 @@ tbody tr:last-child td { .mcp-status-dot.stopped { background: var(--text-tertiary); } .mcp-status-dot.error { background: var(--danger); } +/* ── MCP Server Cards ── */ + +.mcp-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + gap: 16px; +} + +.mcp-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + transition: border-color 0.2s; +} + +.mcp-card:hover { + border-color: var(--border-bright); +} + +.mcp-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.mcp-card-title { + display: flex; + align-items: center; + gap: 10px; +} + +.mcp-card-title h3 { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + margin: 0; + color: var(--text-primary); +} + +.mcp-card-status { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + border-radius: 10px; +} + +.mcp-card-status.running { + color: var(--success); + background: var(--success-bg); +} + +.mcp-card-status.stopped { + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.mcp-card-status.error { + color: var(--danger); + background: var(--danger-bg); +} + +.mcp-card-desc { + font-size: 13px; + color: var(--text-secondary); + margin: 0 0 16px; + line-height: 1.4; +} + +.mcp-card-details { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + padding: 12px; + background: var(--bg-secondary); + border-radius: var(--radius-md); +} + +.mcp-detail-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.mcp-detail-label { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; + min-width: 80px; + flex-shrink: 0; +} + +.mcp-detail-value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + word-break: break-all; +} + +.mcp-card-tools { + margin-bottom: 16px; +} + +.mcp-tools-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.mcp-tool-chip { + font-family: var(--font-mono); + font-size: 11px; + padding: 3px 10px; + background: var(--accent-muted); + color: var(--accent); + border-radius: 12px; + border: 1px solid var(--border-accent); +} + +.mcp-card-token { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + margin-bottom: 12px; +} + +.mcp-token-display { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + color: var(--text-secondary); +} + +.mcp-token-code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + word-break: break-all; +} + +.mcp-token-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.mcp-card-footer { + font-size: 11px; + color: var(--text-tertiary); +} + +/* ── DAST Stat Cards ── */ + +.stat-card-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + text-align: center; +} + +.stat-card-value { + font-family: var(--font-display); + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.stat-card-label { + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ── Button active state ── */ + +.btn-active, +.btn.btn-active { + background: var(--accent-muted); + border-color: var(--accent); + color: var(--accent); +} + .spinner { display: inline-block; width: 14px; diff --git a/compliance-dashboard/src/pages/dast_findings.rs b/compliance-dashboard/src/pages/dast_findings.rs index 9c5b43e..4fa4cac 100644 --- a/compliance-dashboard/src/pages/dast_findings.rs +++ b/compliance-dashboard/src/pages/dast_findings.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; use crate::app::Route; use crate::components::page_header::PageHeader; @@ -10,6 +12,15 @@ pub fn DastFindingsPage() -> Element { let findings = use_resource(|| async { fetch_dast_findings().await.ok() }); rsx! { + div { class: "back-nav", + button { + class: "btn btn-ghost btn-back", + onclick: move |_| { navigator().go_back(); }, + Icon { icon: BsArrowLeft, width: 16, height: 16 } + "Back" + } + } + PageHeader { title: "DAST Findings", description: "Vulnerabilities discovered through dynamic application security testing", diff --git a/compliance-dashboard/src/pages/dast_overview.rs b/compliance-dashboard/src/pages/dast_overview.rs index 3f27f2d..c196c6e 100644 --- a/compliance-dashboard/src/pages/dast_overview.rs +++ b/compliance-dashboard/src/pages/dast_overview.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; use crate::app::Route; use crate::components::page_header::PageHeader; @@ -15,9 +17,9 @@ pub fn DastOverviewPage() -> Element { description: "Dynamic Application Security Testing — scan running applications for vulnerabilities", } - div { class: "grid grid-cols-3 gap-4 mb-6", - div { class: "stat-card", - div { class: "stat-value", + div { class: "stat-cards", style: "margin-bottom: 24px;", + div { class: "stat-card-item", + div { class: "stat-card-value", match &*scan_runs.read() { Some(Some(data)) => { let count = data.total.unwrap_or(0); @@ -26,10 +28,13 @@ pub fn DastOverviewPage() -> Element { _ => rsx! { "—" }, } } - div { class: "stat-label", "Total Scans" } + div { class: "stat-card-label", + Icon { icon: BsPlayCircle, width: 14, height: 14 } + " Total Scans" + } } - div { class: "stat-card", - div { class: "stat-value", + div { class: "stat-card-item", + div { class: "stat-card-value", match &*findings.read() { Some(Some(data)) => { let count = data.total.unwrap_or(0); @@ -38,29 +43,37 @@ pub fn DastOverviewPage() -> Element { _ => rsx! { "—" }, } } - div { class: "stat-label", "DAST Findings" } + div { class: "stat-card-label", + Icon { icon: BsShieldExclamation, width: 14, height: 14 } + " DAST Findings" + } } - div { class: "stat-card", - div { class: "stat-value", "—" } - div { class: "stat-label", "Active Targets" } + div { class: "stat-card-item", + div { class: "stat-card-value", "—" } + div { class: "stat-card-label", + Icon { icon: BsBullseye, width: 14, height: 14 } + " Active Targets" + } } } - div { class: "flex gap-4 mb-4", + div { style: "display: flex; gap: 12px; margin-bottom: 24px;", Link { to: Route::DastTargetsPage {}, class: "btn btn-primary", - "Manage Targets" + Icon { icon: BsBullseye, width: 14, height: 14 } + " Manage Targets" } Link { to: Route::DastFindingsPage {}, class: "btn btn-secondary", - "View Findings" + Icon { icon: BsShieldExclamation, width: 14, height: 14 } + " View Findings" } } div { class: "card", - h3 { "Recent Scan Runs" } + div { class: "card-header", "Recent Scan Runs" } match &*scan_runs.read() { Some(Some(data)) => { let runs = &data.data; diff --git a/compliance-dashboard/src/pages/graph_explorer.rs b/compliance-dashboard/src/pages/graph_explorer.rs index bba7563..472dfd7 100644 --- a/compliance-dashboard/src/pages/graph_explorer.rs +++ b/compliance-dashboard/src/pages/graph_explorer.rs @@ -10,6 +10,36 @@ use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_buil #[component] pub fn GraphExplorerPage(repo_id: String) -> Element { + rsx! { + div { class: "back-nav", + button { + class: "btn btn-ghost btn-back", + onclick: move |_| { navigator().go_back(); }, + Icon { icon: BsArrowLeft, width: 16, height: 16 } + "Back" + } + } + + PageHeader { + title: "Code Knowledge Graph", + description: "Interactive visualization of code structure and relationships", + } + + GraphExplorerBody { repo_id: repo_id } + } +} + +/// Inline variant without back button and page header — for embedding in other pages. +#[component] +pub fn GraphExplorerInline(repo_id: String) -> Element { + rsx! { + GraphExplorerBody { repo_id: repo_id } + } +} + +/// Shared graph explorer body used by both the full page and inline variants. +#[component] +fn GraphExplorerBody(repo_id: String) -> Element { let repo_id_clone = repo_id.clone(); let mut graph_data = use_resource(move || { let rid = repo_id_clone.clone(); @@ -23,22 +53,15 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { let mut building = use_signal(|| false); let mut toasts = use_context::(); - - // Selected node state let mut selected_node = use_signal(|| Option::::None); let mut inspector_open = use_signal(|| false); - - // Search state let mut search_query = use_signal(String::new); let mut search_results = use_signal(Vec::::new); let mut file_filter = use_signal(String::new); - - // Store serialized graph JSON in signals so use_effect can react to them let mut nodes_json = use_signal(String::new); let mut edges_json = use_signal(String::new); let mut graph_ready = use_signal(|| false); - // When resource resolves, serialize the data into signals let graph_data_read = graph_data.read(); if let Some(Some(data)) = &*graph_data_read { if !data.data.nodes.is_empty() && !graph_ready() { @@ -50,7 +73,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { } } - // Derive stats and file tree let (node_count, edge_count, community_count, languages, file_tree_data) = if let Some(Some(data)) = &*graph_data_read { let build = data.data.build.clone().unwrap_or_default(); @@ -82,11 +104,8 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { }; let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty()); - - // Drop the read guard before rendering drop(graph_data_read); - // use_effect runs AFTER DOM commit — this is when #graph-canvas exists use_effect(move || { let ready = graph_ready(); if !ready { @@ -98,7 +117,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { return; } spawn(async move { - // Register the click callback + load graph with a small delay for DOM paint let js = format!( r#" window.__onNodeClick = function(nodeJson) {{ @@ -111,8 +129,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { setTimeout(function() {{ if (window.__loadGraph) {{ window.__loadGraph({nj}, {ej}); - }} else {{ - console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded'); }} }}, 300); "# @@ -121,7 +137,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { }); }); - // Extract selected node fields let sel = selected_node(); let sel_file = sel .as_ref() @@ -148,20 +163,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { .unwrap_or(0) as u32; rsx! { - div { class: "back-nav", - button { - class: "btn btn-ghost btn-back", - onclick: move |_| { navigator().go_back(); }, - Icon { icon: BsArrowLeft, width: 16, height: 16 } - "Back" - } - } - - PageHeader { - title: "Code Knowledge Graph", - description: "Interactive visualization of code structure and relationships", - } - if repo_id.is_empty() { div { class: "card", p { "Select a repository to view its code graph." } diff --git a/compliance-dashboard/src/pages/mcp_servers.rs b/compliance-dashboard/src/pages/mcp_servers.rs index 16fc69a..583dab1 100644 --- a/compliance-dashboard/src/pages/mcp_servers.rs +++ b/compliance-dashboard/src/pages/mcp_servers.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; use crate::components::toast::{ToastType, Toasts}; @@ -26,6 +28,15 @@ pub fn McpServersPage() -> Element { let mut confirm_delete: Signal> = use_signal(|| None); rsx! { + div { class: "back-nav", + button { + class: "btn btn-ghost btn-back", + onclick: move |_| { navigator().go_back(); }, + Icon { icon: BsArrowLeft, width: 16, height: 16 } + "Back" + } + } + PageHeader { title: "MCP Servers", description: "Manage Model Context Protocol servers for LLM integrations", @@ -185,35 +196,37 @@ pub fn McpServersPage() -> Element { if resp.data.is_empty() { rsx! { div { class: "card", - p { class: "text-secondary", "No MCP servers registered. Add one to get started." } + p { style: "padding: 1rem; color: var(--text-secondary);", "No MCP servers registered. Add one to get started." } } } } else { rsx! { - for server in resp.data.iter() { - { - let sid = server.id.map(|id| id.to_hex()).unwrap_or_default(); - let name = server.name.clone(); - let status_class = match server.status { - compliance_core::models::McpServerStatus::Running => "mcp-status-running", - compliance_core::models::McpServerStatus::Stopped => "mcp-status-stopped", - compliance_core::models::McpServerStatus::Error => "mcp-status-error", - }; - let is_token_visible = visible_token().as_deref() == Some(sid.as_str()); - let created_str = server.created_at.format("%Y-%m-%d %H:%M").to_string(); + div { class: "mcp-cards-grid", + for server in resp.data.iter() { + { + let sid = server.id.map(|id| id.to_hex()).unwrap_or_default(); + let name = server.name.clone(); + let status_class = match server.status { + compliance_core::models::McpServerStatus::Running => "running", + compliance_core::models::McpServerStatus::Stopped => "stopped", + compliance_core::models::McpServerStatus::Error => "error", + }; + let status_label = format!("{}", server.status); + let is_token_visible = visible_token().as_deref() == Some(sid.as_str()); + let created_str = server.created_at.format("%Y-%m-%d %H:%M").to_string(); + let tools_count = server.tools_enabled.len(); - rsx! { - div { class: "card mcp-server-card mb-4", - div { class: "mcp-server-header", - div { class: "mcp-server-title", - h3 { "{server.name}" } - span { class: "mcp-status {status_class}", - "{server.status}" + rsx! { + div { class: "mcp-card", + // Header row: status dot + name + actions + div { class: "mcp-card-header", + div { class: "mcp-card-title", + span { class: "mcp-status-dot {status_class}" } + h3 { "{server.name}" } + span { class: "mcp-card-status {status_class}", "{status_label}" } } - } - div { class: "mcp-server-actions", button { - class: "btn btn-sm btn-ghost", + class: "btn btn-sm btn-ghost btn-ghost-danger", title: "Delete server", onclick: { let id = sid.clone(); @@ -222,96 +235,106 @@ pub fn McpServersPage() -> Element { confirm_delete.set(Some((id.clone(), name.clone()))); } }, - "Delete" + Icon { icon: BsTrash, width: 14, height: 14 } } } - } - if let Some(ref desc) = server.description { - p { class: "text-secondary mb-3", "{desc}" } - } + if let Some(ref desc) = server.description { + p { class: "mcp-card-desc", "{desc}" } + } - div { class: "mcp-config-grid", - div { class: "mcp-config-item", - span { class: "mcp-config-label", "Endpoint" } - code { class: "mcp-config-value", "{server.endpoint_url}" } - } - div { class: "mcp-config-item", - span { class: "mcp-config-label", "Transport" } - span { class: "mcp-config-value", "{server.transport}" } - } - if let Some(port) = server.port { - div { class: "mcp-config-item", - span { class: "mcp-config-label", "Port" } - span { class: "mcp-config-value", "{port}" } + // Config details + div { class: "mcp-card-details", + div { class: "mcp-detail-row", + Icon { icon: BsGlobe, width: 13, height: 13 } + span { class: "mcp-detail-label", "Endpoint" } + code { class: "mcp-detail-value", "{server.endpoint_url}" } } - } - if let Some(ref db) = server.mongodb_database { - div { class: "mcp-config-item", - span { class: "mcp-config-label", "Database" } - span { class: "mcp-config-value", "{db}" } + div { class: "mcp-detail-row", + Icon { icon: BsHddNetwork, width: 13, height: 13 } + span { class: "mcp-detail-label", "Transport" } + span { class: "mcp-detail-value", "{server.transport}" } } - } - } - - div { class: "mcp-tools-section", - span { class: "mcp-config-label", "Enabled Tools" } - div { class: "mcp-tools-list", - for tool in server.tools_enabled.iter() { - span { class: "mcp-tool-badge", "{tool}" } - } - } - } - - div { class: "mcp-token-section", - span { class: "mcp-config-label", "Access Token" } - div { class: "mcp-token-row", - code { class: "mcp-token-value", - if is_token_visible { - "{server.access_token}" - } else { - "mcp_••••••••••••••••••••••••••••" + if let Some(port) = server.port { + div { class: "mcp-detail-row", + Icon { icon: BsPlug, width: 13, height: 13 } + span { class: "mcp-detail-label", "Port" } + span { class: "mcp-detail-value", "{port}" } } } - button { - class: "btn btn-sm btn-ghost", - onclick: { - let id = sid.clone(); - move |_| { - if visible_token().as_deref() == Some(id.as_str()) { - visible_token.set(None); - } else { - visible_token.set(Some(id.clone())); - } - } - }, - if is_token_visible { "Hide" } else { "Reveal" } + } + + // Tools + div { class: "mcp-card-tools", + span { class: "mcp-detail-label", + Icon { icon: BsTools, width: 13, height: 13 } + " {tools_count} tools" } - button { - class: "btn btn-sm btn-ghost", - onclick: { - let id = sid.clone(); - move |_| { - let id = id.clone(); - spawn(async move { - match regenerate_mcp_token(id).await { - Ok(_) => { - toasts.push(ToastType::Success, "Token regenerated"); - servers.restart(); - } - Err(e) => toasts.push(ToastType::Error, e.to_string()), - } - }); - } - }, - "Regenerate" + div { class: "mcp-tools-list", + for tool in server.tools_enabled.iter() { + span { class: "mcp-tool-chip", "{tool}" } + } } } - } - div { class: "mcp-meta", - span { class: "text-secondary", - "Created {created_str}" + // Token section + div { class: "mcp-card-token", + div { class: "mcp-token-display", + Icon { icon: BsKey, width: 13, height: 13 } + code { class: "mcp-token-code", + if is_token_visible { + "{server.access_token}" + } else { + "mcp_••••••••••••••••••••" + } + } + } + div { class: "mcp-token-actions", + button { + class: "btn btn-sm btn-ghost", + title: if is_token_visible { "Hide token" } else { "Reveal token" }, + onclick: { + let id = sid.clone(); + move |_| { + if visible_token().as_deref() == Some(id.as_str()) { + visible_token.set(None); + } else { + visible_token.set(Some(id.clone())); + } + } + }, + if is_token_visible { + Icon { icon: BsEyeSlash, width: 14, height: 14 } + } else { + Icon { icon: BsEye, width: 14, height: 14 } + } + } + button { + class: "btn btn-sm btn-ghost", + title: "Regenerate token", + onclick: { + let id = sid.clone(); + move |_| { + let id = id.clone(); + spawn(async move { + match regenerate_mcp_token(id).await { + Ok(_) => { + toasts.push(ToastType::Success, "Token regenerated"); + servers.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + } + }, + Icon { icon: BsArrowRepeat, width: 14, height: 14 } + } + } + } + + // Footer + div { class: "mcp-card-footer", + span { "Created {created_str}" } } } } @@ -321,8 +344,8 @@ pub fn McpServersPage() -> Element { } } }, - Some(None) => rsx! { div { class: "card", p { "Failed to load MCP servers." } } }, - None => rsx! { div { class: "card", p { "Loading..." } } }, + Some(None) => rsx! { div { class: "card", p { style: "padding: 1rem;", "Failed to load MCP servers." } } }, + None => rsx! { div { class: "loading", "Loading..." } }, } } } diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index b866dcd..535a8b2 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -2,10 +2,10 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; -use crate::app::Route; use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; use crate::components::toast::{ToastType, Toasts}; +use crate::pages::graph_explorer::GraphExplorerInline; async fn async_sleep_5s() { #[cfg(feature = "web")] @@ -34,6 +34,7 @@ pub fn RepositoriesPage() -> Element { let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut scanning_ids = use_signal(Vec::::new); + let mut graph_repo_id = use_signal(|| Option::::None); let mut repos = use_resource(move || { let p = page(); @@ -286,10 +287,19 @@ pub fn RepositoriesPage() -> Element { } } td { style: "display: flex; gap: 4px;", - Link { - to: Route::GraphExplorerPage { repo_id: repo_id.clone() }, - class: "btn btn-ghost", + button { + class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" }, title: "View graph", + onclick: { + let rid = repo_id.clone(); + move |_| { + if graph_repo_id().as_deref() == Some(rid.as_str()) { + graph_repo_id.set(None); + } else { + graph_repo_id.set(Some(rid.clone())); + } + } + }, Icon { icon: BsDiagram3, width: 16, height: 16 } } button { @@ -354,6 +364,22 @@ pub fn RepositoriesPage() -> Element { on_page_change: move |p| page.set(p), } } + + // Inline graph explorer + if let Some(rid) = graph_repo_id() { + div { class: "card", style: "margin-top: 16px;", + div { class: "card-header", style: "display: flex; justify-content: space-between; align-items: center;", + span { "Code Graph" } + button { + class: "btn btn-sm btn-ghost", + title: "Close graph", + onclick: move |_| { graph_repo_id.set(None); }, + Icon { icon: BsX, width: 18, height: 18 } + } + } + GraphExplorerInline { repo_id: rid } + } + } } }, Some(None) => rsx! {