Compare commits
1 Commits
feat/ui-im
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0065c7c4b2 |
@@ -323,6 +323,25 @@ code {
|
|||||||
|
|
||||||
/* ── Page Header ── */
|
/* ── 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 {
|
.page-header {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
@@ -479,7 +498,7 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 11px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -505,7 +524,8 @@ tbody tr:last-child td {
|
|||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3px 10px;
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -617,6 +637,298 @@ tbody tr:last-child td {
|
|||||||
gap: 6px;
|
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 {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
|
|||||||
@@ -42,26 +42,11 @@ pub fn Sidebar() -> Element {
|
|||||||
route: Route::IssuesPage {},
|
route: Route::IssuesPage {},
|
||||||
icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } },
|
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 {
|
NavItem {
|
||||||
label: "DAST",
|
label: "DAST",
|
||||||
route: Route::DastOverviewPage {},
|
route: Route::DastOverviewPage {},
|
||||||
icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } },
|
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 {
|
NavItem {
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
route: Route::SettingsPage {},
|
route: Route::SettingsPage {},
|
||||||
@@ -90,10 +75,6 @@ pub fn Sidebar() -> Element {
|
|||||||
{
|
{
|
||||||
let is_active = match (¤t_route, &item.route) {
|
let is_active = match (¤t_route, &item.route) {
|
||||||
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
|
(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::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::infrastructure::chat::{
|
use crate::infrastructure::chat::{
|
||||||
@@ -179,6 +181,15 @@ pub fn ChatPage(repo_id: String) -> Element {
|
|||||||
let mut do_send_click = do_send.clone();
|
let mut do_send_click = do_send.clone();
|
||||||
|
|
||||||
rsx! {
|
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" }
|
PageHeader { title: "AI Chat" }
|
||||||
|
|
||||||
// Embedding status banner
|
// Embedding status banner
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::severity_badge::SeverityBadge;
|
use crate::components::severity_badge::SeverityBadge;
|
||||||
@@ -12,6 +14,15 @@ pub fn DastFindingDetailPage(id: String) -> Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: "DAST Finding Detail",
|
title: "DAST Finding Detail",
|
||||||
description: "Full evidence and details for a dynamic security finding",
|
description: "Full evidence and details for a dynamic security finding",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -10,6 +12,15 @@ pub fn DastFindingsPage() -> Element {
|
|||||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||||
|
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: "DAST Findings",
|
title: "DAST Findings",
|
||||||
description: "Vulnerabilities discovered through dynamic application security testing",
|
description: "Vulnerabilities discovered through dynamic application security testing",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -15,9 +17,9 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "grid grid-cols-3 gap-4 mb-6",
|
div { class: "stat-cards", style: "margin-bottom: 24px;",
|
||||||
div { class: "stat-card",
|
div { class: "stat-card-item",
|
||||||
div { class: "stat-value",
|
div { class: "stat-card-value",
|
||||||
match &*scan_runs.read() {
|
match &*scan_runs.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let count = data.total.unwrap_or(0);
|
let count = data.total.unwrap_or(0);
|
||||||
@@ -26,10 +28,13 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
_ => rsx! { "—" },
|
_ => 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-card-item",
|
||||||
div { class: "stat-value",
|
div { class: "stat-card-value",
|
||||||
match &*findings.read() {
|
match &*findings.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let count = data.total.unwrap_or(0);
|
let count = data.total.unwrap_or(0);
|
||||||
@@ -38,29 +43,37 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
_ => rsx! { "—" },
|
_ => 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-card-item",
|
||||||
div { class: "stat-value", "—" }
|
div { class: "stat-card-value", "—" }
|
||||||
div { class: "stat-label", "Active Targets" }
|
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 {
|
Link {
|
||||||
to: Route::DastTargetsPage {},
|
to: Route::DastTargetsPage {},
|
||||||
class: "btn btn-primary",
|
class: "btn btn-primary",
|
||||||
"Manage Targets"
|
Icon { icon: BsBullseye, width: 14, height: 14 }
|
||||||
|
" Manage Targets"
|
||||||
}
|
}
|
||||||
Link {
|
Link {
|
||||||
to: Route::DastFindingsPage {},
|
to: Route::DastFindingsPage {},
|
||||||
class: "btn btn-secondary",
|
class: "btn btn-secondary",
|
||||||
"View Findings"
|
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||||
|
" View Findings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
h3 { "Recent Scan Runs" }
|
div { class: "card-header", "Recent Scan Runs" }
|
||||||
match &*scan_runs.read() {
|
match &*scan_runs.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let runs = &data.data;
|
let runs = &data.data;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::toast::{ToastType, Toasts};
|
use crate::components::toast::{ToastType, Toasts};
|
||||||
@@ -14,6 +16,15 @@ pub fn DastTargetsPage() -> Element {
|
|||||||
let mut new_url = use_signal(String::new);
|
let mut new_url = use_signal(String::new);
|
||||||
|
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: "DAST Targets",
|
title: "DAST Targets",
|
||||||
description: "Configure target applications for dynamic security testing",
|
description: "Configure target applications for dynamic security testing",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::code_snippet::CodeSnippet;
|
use crate::components::code_snippet::CodeSnippet;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -25,6 +27,15 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
let finding_id_for_feedback = id.clone();
|
let finding_id_for_feedback = id.clone();
|
||||||
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: f.title.clone(),
|
title: f.title.clone(),
|
||||||
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
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 status_str = status.to_string();
|
||||||
let id_clone = finding_id_for_status.clone();
|
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! {
|
rsx! {
|
||||||
button {
|
button {
|
||||||
class: "btn btn-ghost",
|
class: "btn btn-ghost",
|
||||||
|
title: "{label}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let s = status_str.clone();
|
let s = status_str.clone();
|
||||||
let id = id_clone.clone();
|
let id = id_clone.clone();
|
||||||
@@ -119,7 +139,15 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
});
|
});
|
||||||
finding.restart();
|
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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -159,6 +161,7 @@ pub fn FindingsPage() -> Element {
|
|||||||
rsx! {
|
rsx! {
|
||||||
button {
|
button {
|
||||||
class: "btn btn-sm btn-ghost",
|
class: "btn btn-sm btn-ghost",
|
||||||
|
title: "Mark {label}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let ids = selected_ids();
|
let ids = selected_ids();
|
||||||
let s = status_str.clone();
|
let s = status_str.clone();
|
||||||
@@ -168,7 +171,14 @@ pub fn FindingsPage() -> Element {
|
|||||||
});
|
});
|
||||||
selected_ids.set(Vec::new());
|
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.scan_type}" }
|
||||||
td { "{finding.scanner}" }
|
|
||||||
td {
|
td {
|
||||||
style: "font-family: monospace; font-size: 12px;",
|
Icon { icon: BsCpu, width: 14, height: 14 }
|
||||||
"{finding.file_path.as_deref().unwrap_or(\"-\")}"
|
" {finding.scanner}"
|
||||||
}
|
}
|
||||||
td {
|
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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::code_inspector::CodeInspector;
|
use crate::components::code_inspector::CodeInspector;
|
||||||
use crate::components::file_tree::{build_file_tree, FileTree};
|
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]
|
#[component]
|
||||||
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
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 repo_id_clone = repo_id.clone();
|
||||||
let mut graph_data = use_resource(move || {
|
let mut graph_data = use_resource(move || {
|
||||||
let rid = repo_id_clone.clone();
|
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 building = use_signal(|| false);
|
||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
|
||||||
// Selected node state
|
|
||||||
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
||||||
let mut inspector_open = use_signal(|| false);
|
let mut inspector_open = use_signal(|| false);
|
||||||
|
|
||||||
// Search state
|
|
||||||
let mut search_query = use_signal(String::new);
|
let mut search_query = use_signal(String::new);
|
||||||
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
||||||
let mut file_filter = use_signal(String::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 nodes_json = use_signal(String::new);
|
||||||
let mut edges_json = use_signal(String::new);
|
let mut edges_json = use_signal(String::new);
|
||||||
let mut graph_ready = use_signal(|| false);
|
let mut graph_ready = use_signal(|| false);
|
||||||
|
|
||||||
// When resource resolves, serialize the data into signals
|
|
||||||
let graph_data_read = graph_data.read();
|
let graph_data_read = graph_data.read();
|
||||||
if let Some(Some(data)) = &*graph_data_read {
|
if let Some(Some(data)) = &*graph_data_read {
|
||||||
if !data.data.nodes.is_empty() && !graph_ready() {
|
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) =
|
let (node_count, edge_count, community_count, languages, file_tree_data) =
|
||||||
if let Some(Some(data)) = &*graph_data_read {
|
if let Some(Some(data)) = &*graph_data_read {
|
||||||
let build = data.data.build.clone().unwrap_or_default();
|
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());
|
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);
|
drop(graph_data_read);
|
||||||
|
|
||||||
// use_effect runs AFTER DOM commit — this is when #graph-canvas exists
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let ready = graph_ready();
|
let ready = graph_ready();
|
||||||
if !ready {
|
if !ready {
|
||||||
@@ -96,7 +117,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
// Register the click callback + load graph with a small delay for DOM paint
|
|
||||||
let js = format!(
|
let js = format!(
|
||||||
r#"
|
r#"
|
||||||
window.__onNodeClick = function(nodeJson) {{
|
window.__onNodeClick = function(nodeJson) {{
|
||||||
@@ -109,8 +129,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
setTimeout(function() {{
|
setTimeout(function() {{
|
||||||
if (window.__loadGraph) {{
|
if (window.__loadGraph) {{
|
||||||
window.__loadGraph({nj}, {ej});
|
window.__loadGraph({nj}, {ej});
|
||||||
}} else {{
|
|
||||||
console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded');
|
|
||||||
}}
|
}}
|
||||||
}}, 300);
|
}}, 300);
|
||||||
"#
|
"#
|
||||||
@@ -119,7 +137,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract selected node fields
|
|
||||||
let sel = selected_node();
|
let sel = selected_node();
|
||||||
let sel_file = sel
|
let sel_file = sel
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -146,11 +163,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
|
||||||
title: "Code Knowledge Graph",
|
|
||||||
description: "Interactive visualization of code structure and relationships",
|
|
||||||
}
|
|
||||||
|
|
||||||
if repo_id.is_empty() {
|
if repo_id.is_empty() {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
p { "Select a repository to view its code graph." }
|
p { "Select a repository to view its code graph." }
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::infrastructure::graph::fetch_impact;
|
use crate::infrastructure::graph::fetch_impact;
|
||||||
@@ -12,6 +14,15 @@ pub fn ImpactAnalysisPage(repo_id: String, finding_id: String) -> Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: "Impact Analysis",
|
title: "Impact Analysis",
|
||||||
description: "Blast radius and affected entry points for a security finding",
|
description: "Blast radius and affected entry points for a security finding",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::toast::{ToastType, Toasts};
|
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);
|
let mut confirm_delete: Signal<Option<(String, String)>> = use_signal(|| None);
|
||||||
|
|
||||||
rsx! {
|
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 {
|
PageHeader {
|
||||||
title: "MCP Servers",
|
title: "MCP Servers",
|
||||||
description: "Manage Model Context Protocol servers for LLM integrations",
|
description: "Manage Model Context Protocol servers for LLM integrations",
|
||||||
@@ -185,35 +196,37 @@ pub fn McpServersPage() -> Element {
|
|||||||
if resp.data.is_empty() {
|
if resp.data.is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "card",
|
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 {
|
} else {
|
||||||
rsx! {
|
rsx! {
|
||||||
for server in resp.data.iter() {
|
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 sid = server.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||||
let status_class = match server.status {
|
let name = server.name.clone();
|
||||||
compliance_core::models::McpServerStatus::Running => "mcp-status-running",
|
let status_class = match server.status {
|
||||||
compliance_core::models::McpServerStatus::Stopped => "mcp-status-stopped",
|
compliance_core::models::McpServerStatus::Running => "running",
|
||||||
compliance_core::models::McpServerStatus::Error => "mcp-status-error",
|
compliance_core::models::McpServerStatus::Stopped => "stopped",
|
||||||
};
|
compliance_core::models::McpServerStatus::Error => "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();
|
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! {
|
rsx! {
|
||||||
div { class: "card mcp-server-card mb-4",
|
div { class: "mcp-card",
|
||||||
div { class: "mcp-server-header",
|
// Header row: status dot + name + actions
|
||||||
div { class: "mcp-server-title",
|
div { class: "mcp-card-header",
|
||||||
h3 { "{server.name}" }
|
div { class: "mcp-card-title",
|
||||||
span { class: "mcp-status {status_class}",
|
span { class: "mcp-status-dot {status_class}" }
|
||||||
"{server.status}"
|
h3 { "{server.name}" }
|
||||||
|
span { class: "mcp-card-status {status_class}", "{status_label}" }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
div { class: "mcp-server-actions",
|
|
||||||
button {
|
button {
|
||||||
class: "btn btn-sm btn-ghost",
|
class: "btn btn-sm btn-ghost btn-ghost-danger",
|
||||||
title: "Delete server",
|
title: "Delete server",
|
||||||
onclick: {
|
onclick: {
|
||||||
let id = sid.clone();
|
let id = sid.clone();
|
||||||
@@ -222,96 +235,106 @@ pub fn McpServersPage() -> Element {
|
|||||||
confirm_delete.set(Some((id.clone(), name.clone())));
|
confirm_delete.set(Some((id.clone(), name.clone())));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Delete"
|
Icon { icon: BsTrash, width: 14, height: 14 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref desc) = server.description {
|
if let Some(ref desc) = server.description {
|
||||||
p { class: "text-secondary mb-3", "{desc}" }
|
p { class: "mcp-card-desc", "{desc}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "mcp-config-grid",
|
// Config details
|
||||||
div { class: "mcp-config-item",
|
div { class: "mcp-card-details",
|
||||||
span { class: "mcp-config-label", "Endpoint" }
|
div { class: "mcp-detail-row",
|
||||||
code { class: "mcp-config-value", "{server.endpoint_url}" }
|
Icon { icon: BsGlobe, width: 13, height: 13 }
|
||||||
}
|
span { class: "mcp-detail-label", "Endpoint" }
|
||||||
div { class: "mcp-config-item",
|
code { class: "mcp-detail-value", "{server.endpoint_url}" }
|
||||||
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}" }
|
|
||||||
}
|
}
|
||||||
}
|
div { class: "mcp-detail-row",
|
||||||
if let Some(ref db) = server.mongodb_database {
|
Icon { icon: BsHddNetwork, width: 13, height: 13 }
|
||||||
div { class: "mcp-config-item",
|
span { class: "mcp-detail-label", "Transport" }
|
||||||
span { class: "mcp-config-label", "Database" }
|
span { class: "mcp-detail-value", "{server.transport}" }
|
||||||
span { class: "mcp-config-value", "{db}" }
|
|
||||||
}
|
}
|
||||||
}
|
if let Some(port) = server.port {
|
||||||
}
|
div { class: "mcp-detail-row",
|
||||||
|
Icon { icon: BsPlug, width: 13, height: 13 }
|
||||||
div { class: "mcp-tools-section",
|
span { class: "mcp-detail-label", "Port" }
|
||||||
span { class: "mcp-config-label", "Enabled Tools" }
|
span { class: "mcp-detail-value", "{port}" }
|
||||||
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_••••••••••••••••••••••••••••"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
button {
|
}
|
||||||
class: "btn btn-sm btn-ghost",
|
|
||||||
onclick: {
|
// Tools
|
||||||
let id = sid.clone();
|
div { class: "mcp-card-tools",
|
||||||
move |_| {
|
span { class: "mcp-detail-label",
|
||||||
if visible_token().as_deref() == Some(id.as_str()) {
|
Icon { icon: BsTools, width: 13, height: 13 }
|
||||||
visible_token.set(None);
|
" {tools_count} tools"
|
||||||
} else {
|
|
||||||
visible_token.set(Some(id.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
if is_token_visible { "Hide" } else { "Reveal" }
|
|
||||||
}
|
}
|
||||||
button {
|
div { class: "mcp-tools-list",
|
||||||
class: "btn btn-sm btn-ghost",
|
for tool in server.tools_enabled.iter() {
|
||||||
onclick: {
|
span { class: "mcp-tool-chip", "{tool}" }
|
||||||
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-meta",
|
// Token section
|
||||||
span { class: "text-secondary",
|
div { class: "mcp-card-token",
|
||||||
"Created {created_str}"
|
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." } } },
|
Some(None) => rsx! { div { class: "card", p { style: "padding: 1rem;", "Failed to load MCP servers." } } },
|
||||||
None => rsx! { div { class: "card", p { "Loading..." } } },
|
None => rsx! { div { class: "loading", "Loading..." } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use dioxus::prelude::*;
|
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::page_header::PageHeader;
|
||||||
use crate::components::stat_card::StatCard;
|
use crate::components::stat_card::StatCard;
|
||||||
|
use crate::infrastructure::mcp::fetch_mcp_servers;
|
||||||
|
use crate::infrastructure::repositories::fetch_repositories;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::infrastructure::stats::fetch_overview_stats;
|
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! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "Overview",
|
title: "Overview",
|
||||||
@@ -66,6 +74,125 @@ pub fn OverviewPage() -> Element {
|
|||||||
SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" }
|
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! {
|
Some(None) => rsx! {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use dioxus::prelude::*;
|
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::page_header::PageHeader;
|
||||||
use crate::components::pagination::Pagination;
|
use crate::components::pagination::Pagination;
|
||||||
use crate::components::toast::{ToastType, Toasts};
|
use crate::components::toast::{ToastType, Toasts};
|
||||||
|
use crate::pages::graph_explorer::GraphExplorerInline;
|
||||||
|
|
||||||
async fn async_sleep_5s() {
|
async fn async_sleep_5s() {
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
@@ -32,6 +34,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
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 mut repos = use_resource(move || {
|
||||||
let p = page();
|
let p = page();
|
||||||
@@ -284,13 +287,24 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { style: "display: flex; gap: 4px;",
|
td { style: "display: flex; gap: 4px;",
|
||||||
Link {
|
button {
|
||||||
to: Route::GraphExplorerPage { repo_id: repo_id.clone() },
|
class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" },
|
||||||
class: "btn btn-ghost",
|
title: "View graph",
|
||||||
"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 {
|
button {
|
||||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||||
|
title: "Trigger scan",
|
||||||
disabled: is_scanning,
|
disabled: is_scanning,
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let id = repo_id_scan.clone();
|
let id = repo_id_scan.clone();
|
||||||
@@ -324,17 +338,17 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
},
|
},
|
||||||
if is_scanning {
|
if is_scanning {
|
||||||
span { class: "spinner" }
|
span { class: "spinner" }
|
||||||
"Scanning..."
|
|
||||||
} else {
|
} else {
|
||||||
"Scan"
|
Icon { icon: BsPlayCircle, width: 16, height: 16 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "btn btn-ghost btn-ghost-danger",
|
class: "btn btn-ghost btn-ghost-danger",
|
||||||
|
title: "Delete repository",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone())));
|
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),
|
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! {
|
Some(None) => rsx! {
|
||||||
|
|||||||
Reference in New Issue
Block a user