feat: UI improvements with icons, back navigation, and overview cards #7

Merged
sharang merged 2 commits from feat/ui-improvements into main 2026-03-09 17:09:41 +00:00
6 changed files with 425 additions and 149 deletions
Showing only changes of commit 494600b43f - Show all commits

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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::<Toasts>();
// Selected node state
let mut selected_node = use_signal(|| Option::<serde_json::Value>::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::<serde_json::Value>::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." }

View File

@@ -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<Option<(String, String)>> = 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..." } },
}
}
}

View File

@@ -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::<Toasts>();
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
let mut scanning_ids = use_signal(Vec::<String>::new);
let mut graph_repo_id = use_signal(|| Option::<String>::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! {