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
11 changed files with 362 additions and 31 deletions
Showing only changes of commit c44bf12a4a - Show all commits

View File

@@ -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,96 @@ 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); }
.spinner {
display: inline-block;
width: 14px;

View File

@@ -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 (&current_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,

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::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

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

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

View File

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

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

View File

@@ -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};
@@ -146,6 +148,15 @@ 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",

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

View File

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

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;
@@ -287,10 +289,12 @@ pub fn RepositoriesPage() -> Element {
Link {
to: Route::GraphExplorerPage { repo_id: repo_id.clone() },
class: "btn btn-ghost",
"Graph"
title: "View graph",
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 +328,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 }
}
}
}