From 0065c7c4b231b8d7f4d48a652ff53b6c9c12a4b9 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 9 Mar 2026 17:09:40 +0000 Subject: [PATCH] feat: UI improvements with icons, back navigation, and overview cards (#7) --- compliance-dashboard/assets/main.css | 316 +++++++++++++++++- .../src/components/sidebar.rs | 19 -- compliance-dashboard/src/pages/chat.rs | 11 + .../src/pages/dast_finding_detail.rs | 11 + .../src/pages/dast_findings.rs | 11 + .../src/pages/dast_overview.rs | 41 ++- .../src/pages/dast_targets.rs | 11 + .../src/pages/finding_detail.rs | 30 +- compliance-dashboard/src/pages/findings.rs | 36 +- .../src/pages/graph_explorer.rs | 52 +-- .../src/pages/impact_analysis.rs | 11 + compliance-dashboard/src/pages/mcp_servers.rs | 227 +++++++------ compliance-dashboard/src/pages/overview.rs | 127 +++++++ .../src/pages/repositories.rs | 46 ++- 14 files changed, 778 insertions(+), 171 deletions(-) diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 11b5096..8a8cf52 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -323,6 +323,25 @@ code { /* ── Page Header ── */ +/* ── Back Navigation ── */ + +.back-nav { + margin-bottom: 12px; +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + padding: 6px 12px; + color: var(--text-secondary); +} + +.btn-back:hover { + color: var(--text-primary); +} + .page-header { margin-bottom: 28px; padding-bottom: 20px; @@ -479,7 +498,7 @@ th { } td { - padding: 11px 16px; + padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13.5px; color: var(--text-primary); @@ -505,7 +524,8 @@ tbody tr:last-child td { .badge { display: inline-flex; align-items: center; - padding: 3px 10px; + gap: 5px; + padding: 4px 10px; border-radius: 6px; font-family: var(--font-mono); font-size: 11px; @@ -617,6 +637,298 @@ tbody tr:last-child td { gap: 6px; } +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 8px; + min-width: 32px; +} + +/* ── Overview Cards Grid ── */ + +.overview-section { + margin-top: 28px; +} + +.overview-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.overview-section-header h3 { + font-family: var(--font-display); + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.overview-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; +} + +.overview-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + transition: border-color 0.2s, box-shadow 0.2s; + text-decoration: none; + color: inherit; + display: flex; + align-items: center; + gap: 12px; +} + +.overview-card:hover { + border-color: var(--accent); + box-shadow: 0 0 16px rgba(0, 200, 255, 0.06); +} + +.overview-card-icon { + color: var(--accent); + flex-shrink: 0; +} + +.overview-card-body { + min-width: 0; +} + +.overview-card-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.overview-card-sub { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mcp-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.mcp-status-dot.running { background: var(--success); } +.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/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index eb2c6d6..4356c1a 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -42,26 +42,11 @@ pub fn Sidebar() -> Element { route: Route::IssuesPage {}, icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } }, }, - NavItem { - label: "Code Graph", - route: Route::GraphIndexPage {}, - icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } }, - }, - NavItem { - label: "AI Chat", - route: Route::ChatIndexPage {}, - icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } }, - }, NavItem { label: "DAST", route: Route::DastOverviewPage {}, icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } }, }, - NavItem { - label: "MCP Servers", - route: Route::McpServersPage {}, - icon: rsx! { Icon { icon: BsPlug, width: 18, height: 18 } }, - }, NavItem { label: "Settings", route: Route::SettingsPage {}, @@ -90,10 +75,6 @@ pub fn Sidebar() -> Element { { let is_active = match (¤t_route, &item.route) { (Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true, - (Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true, - (Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true, - (Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true, - (Route::ChatPage { .. }, Route::ChatIndexPage {}) => true, (Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true, (Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true, (Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true, diff --git a/compliance-dashboard/src/pages/chat.rs b/compliance-dashboard/src/pages/chat.rs index e7cd02a..6833da6 100644 --- a/compliance-dashboard/src/pages/chat.rs +++ b/compliance-dashboard/src/pages/chat.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::infrastructure::chat::{ @@ -179,6 +181,15 @@ pub fn ChatPage(repo_id: String) -> Element { let mut do_send_click = do_send.clone(); 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: "AI Chat" } // Embedding status banner diff --git a/compliance-dashboard/src/pages/dast_finding_detail.rs b/compliance-dashboard/src/pages/dast_finding_detail.rs index 6c14650..5c28459 100644 --- a/compliance-dashboard/src/pages/dast_finding_detail.rs +++ b/compliance-dashboard/src/pages/dast_finding_detail.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::severity_badge::SeverityBadge; @@ -12,6 +14,15 @@ pub fn DastFindingDetailPage(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: "DAST Finding Detail", description: "Full evidence and details for a dynamic security finding", 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/dast_targets.rs b/compliance-dashboard/src/pages/dast_targets.rs index 20ba254..a233406 100644 --- a/compliance-dashboard/src/pages/dast_targets.rs +++ b/compliance-dashboard/src/pages/dast_targets.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}; @@ -14,6 +16,15 @@ pub fn DastTargetsPage() -> Element { let mut new_url = use_signal(String::new); 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 Targets", description: "Configure target applications for dynamic security testing", diff --git a/compliance-dashboard/src/pages/finding_detail.rs b/compliance-dashboard/src/pages/finding_detail.rs index 94629ac..341bc95 100644 --- a/compliance-dashboard/src/pages/finding_detail.rs +++ b/compliance-dashboard/src/pages/finding_detail.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; use crate::components::code_snippet::CodeSnippet; use crate::components::page_header::PageHeader; @@ -25,6 +27,15 @@ pub fn FindingDetailPage(id: String) -> Element { let finding_id_for_feedback = id.clone(); let existing_feedback = f.developer_feedback.clone().unwrap_or_default(); 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: f.title.clone(), description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status), @@ -108,9 +119,18 @@ pub fn FindingDetailPage(id: String) -> Element { { let status_str = status.to_string(); let id_clone = finding_id_for_status.clone(); + let label = match status { + "open" => "Open", + "triaged" => "Triaged", + "resolved" => "Resolved", + "false_positive" => "False Positive", + "ignored" => "Ignored", + _ => status, + }; rsx! { button { class: "btn btn-ghost", + title: "{label}", onclick: move |_| { let s = status_str.clone(); let id = id_clone.clone(); @@ -119,7 +139,15 @@ pub fn FindingDetailPage(id: String) -> Element { }); finding.restart(); }, - "{status}" + match status { + "open" => rsx! { Icon { icon: BsCircle, width: 14, height: 14 } }, + "triaged" => rsx! { Icon { icon: BsEye, width: 14, height: 14 } }, + "resolved" => rsx! { Icon { icon: BsCheckCircle, width: 14, height: 14 } }, + "false_positive" => rsx! { Icon { icon: BsXCircle, width: 14, height: 14 } }, + "ignored" => rsx! { Icon { icon: BsDashCircle, width: 14, height: 14 } }, + _ => rsx! {}, + } + " {label}" } } } diff --git a/compliance-dashboard/src/pages/findings.rs b/compliance-dashboard/src/pages/findings.rs index 8b25678..8784fe2 100644 --- a/compliance-dashboard/src/pages/findings.rs +++ b/compliance-dashboard/src/pages/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; @@ -159,6 +161,7 @@ pub fn FindingsPage() -> Element { rsx! { button { class: "btn btn-sm btn-ghost", + title: "Mark {label}", onclick: move |_| { let ids = selected_ids(); let s = status_str.clone(); @@ -168,7 +171,14 @@ pub fn FindingsPage() -> Element { }); selected_ids.set(Vec::new()); }, - "Mark {label}" + match status { + "triaged" => rsx! { Icon { icon: BsEye, width: 14, height: 14 } }, + "resolved" => rsx! { Icon { icon: BsCheckCircle, width: 14, height: 14 } }, + "false_positive" => rsx! { Icon { icon: BsXCircle, width: 14, height: 14 } }, + "ignored" => rsx! { Icon { icon: BsDashCircle, width: 14, height: 14 } }, + _ => rsx! {}, + } + " {label}" } } } @@ -261,13 +271,29 @@ pub fn FindingsPage() -> Element { } } td { "{finding.scan_type}" } - td { "{finding.scanner}" } td { - style: "font-family: monospace; font-size: 12px;", - "{finding.file_path.as_deref().unwrap_or(\"-\")}" + Icon { icon: BsCpu, width: 14, height: 14 } + " {finding.scanner}" } td { - span { class: "badge badge-info", "{finding.status}" } + style: "font-family: monospace; font-size: 12px;", + Icon { icon: BsFileEarmarkCode, width: 14, height: 14 } + " {finding.file_path.as_deref().unwrap_or(\"-\")}" + } + td { + span { class: "badge badge-info", + { + use compliance_core::models::FindingStatus; + match &finding.status { + FindingStatus::Open => rsx! { Icon { icon: BsCircle, width: 12, height: 12 } }, + FindingStatus::Triaged => rsx! { Icon { icon: BsEye, width: 12, height: 12 } }, + FindingStatus::Resolved => rsx! { Icon { icon: BsCheckCircle, width: 12, height: 12 } }, + FindingStatus::FalsePositive => rsx! { Icon { icon: BsXCircle, width: 12, height: 12 } }, + FindingStatus::Ignored => rsx! { Icon { icon: BsDashCircle, width: 12, height: 12 } }, + } + } + " {finding.status}" + } } } } diff --git a/compliance-dashboard/src/pages/graph_explorer.rs b/compliance-dashboard/src/pages/graph_explorer.rs index 93c14d3..472dfd7 100644 --- a/compliance-dashboard/src/pages/graph_explorer.rs +++ b/compliance-dashboard/src/pages/graph_explorer.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; use crate::components::code_inspector::CodeInspector; use crate::components::file_tree::{build_file_tree, FileTree}; @@ -8,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(); @@ -21,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() { @@ -48,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(); @@ -80,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 { @@ -96,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) {{ @@ -109,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); "# @@ -119,7 +137,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { }); }); - // Extract selected node fields let sel = selected_node(); let sel_file = sel .as_ref() @@ -146,11 +163,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element { .unwrap_or(0) as u32; rsx! { - 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/impact_analysis.rs b/compliance-dashboard/src/pages/impact_analysis.rs index a58e77d..8e934f7 100644 --- a/compliance-dashboard/src/pages/impact_analysis.rs +++ b/compliance-dashboard/src/pages/impact_analysis.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::infrastructure::graph::fetch_impact; @@ -12,6 +14,15 @@ pub fn ImpactAnalysisPage(repo_id: String, finding_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: "Impact Analysis", description: "Blast radius and affected entry points for a security finding", 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/overview.rs b/compliance-dashboard/src/pages/overview.rs index a69301b..fc25dc4 100644 --- a/compliance-dashboard/src/pages/overview.rs +++ b/compliance-dashboard/src/pages/overview.rs @@ -1,7 +1,12 @@ 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::stat_card::StatCard; +use crate::infrastructure::mcp::fetch_mcp_servers; +use crate::infrastructure::repositories::fetch_repositories; #[cfg(feature = "server")] use crate::infrastructure::stats::fetch_overview_stats; @@ -21,6 +26,9 @@ pub fn OverviewPage() -> Element { } }); + let repos = use_resource(|| async { fetch_repositories(1).await.ok() }); + let mcp_servers = use_resource(|| async { fetch_mcp_servers().await.ok() }); + rsx! { PageHeader { title: "Overview", @@ -66,6 +74,125 @@ pub fn OverviewPage() -> Element { SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" } } } + + // AI Chat section + div { class: "card", + div { class: "card-header", "AI Chat" } + match &*repos.read() { + Some(Some(data)) => { + let repo_list = &data.data; + if repo_list.is_empty() { + rsx! { + p { style: "padding: 1rem; color: var(--text-secondary);", + "No repositories found. Add a repository to start chatting." + } + } + } else { + rsx! { + div { + class: "grid", + style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1rem;", + for repo in repo_list { + { + let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + let name = repo.name.clone(); + rsx! { + Link { + to: Route::ChatPage { repo_id }, + class: "graph-repo-card", + div { class: "graph-repo-card-header", + div { class: "graph-repo-card-icon", + Icon { icon: BsChatDots, width: 20, height: 20 } + } + h3 { class: "graph-repo-card-name", "{name}" } + } + } + } + } + } + } + } + } + }, + Some(None) => rsx! { + p { style: "padding: 1rem; color: var(--text-secondary);", + "Failed to load repositories." + } + }, + None => rsx! { + div { class: "loading", "Loading repositories..." } + }, + } + } + + // MCP Servers section + div { class: "card", + div { class: "card-header", "MCP Servers" } + match &*mcp_servers.read() { + Some(Some(resp)) => { + if resp.data.is_empty() { + rsx! { + p { style: "padding: 1rem; color: var(--text-secondary);", + "No MCP servers registered." + } + } + } else { + rsx! { + div { + style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1rem;", + for server in resp.data.iter() { + { + let status_color = match server.status { + compliance_core::models::McpServerStatus::Running => "var(--success)", + compliance_core::models::McpServerStatus::Stopped => "var(--text-secondary)", + compliance_core::models::McpServerStatus::Error => "var(--danger)", + }; + let status_label = format!("{}", server.status); + let endpoint = server.endpoint_url.clone(); + let name = server.name.clone(); + rsx! { + div { class: "card", + style: "padding: 0.75rem;", + div { + style: "display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;", + span { + style: "width: 8px; height: 8px; border-radius: 50%; background: {status_color}; display: inline-block;", + } + strong { "{name}" } + } + p { + style: "font-size: 0.8rem; color: var(--text-secondary); margin: 0; word-break: break-all;", + "{endpoint}" + } + p { + style: "font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;", + "{status_label}" + } + } + } + } + } + } + div { style: "padding: 0 1rem 1rem;", + Link { + to: Route::McpServersPage {}, + class: "btn btn-primary btn-sm", + "Manage" + } + } + } + } + }, + Some(None) => rsx! { + p { style: "padding: 1rem; color: var(--text-secondary);", + "Failed to load MCP servers." + } + }, + None => rsx! { + div { class: "loading", "Loading..." } + }, + } + } }, Some(None) => rsx! { div { class: "card", diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 3f91d11..535a8b2 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -1,9 +1,11 @@ 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")] @@ -32,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(); @@ -284,13 +287,24 @@ pub fn RepositoriesPage() -> Element { } } td { style: "display: flex; gap: 4px;", - Link { - to: Route::GraphExplorerPage { repo_id: repo_id.clone() }, - class: "btn btn-ghost", - "Graph" + 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 { class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, + title: "Trigger scan", disabled: is_scanning, onclick: move |_| { let id = repo_id_scan.clone(); @@ -324,17 +338,17 @@ pub fn RepositoriesPage() -> Element { }, if is_scanning { span { class: "spinner" } - "Scanning..." } else { - "Scan" + Icon { icon: BsPlayCircle, width: 16, height: 16 } } } button { class: "btn btn-ghost btn-ghost-danger", + title: "Delete repository", onclick: move |_| { confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone()))); }, - "Delete" + Icon { icon: BsTrash, width: 16, height: 16 } } } } @@ -350,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! {