Initial commit: Compliance Scanner Agent
Autonomous security and compliance scanning agent for git repositories. Features: SAST (Semgrep), SBOM (Syft), CVE monitoring (OSV.dev/NVD), GDPR/OAuth pattern detection, LLM triage, issue creation (GitHub/GitLab/Jira), PR reviews, and Dioxus fullstack dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
compliance-dashboard/Cargo.toml
Normal file
54
compliance-dashboard/Cargo.toml
Normal file
@@ -0,0 +1,54 @@
|
||||
[package]
|
||||
name = "compliance-dashboard"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "compliance-dashboard"
|
||||
|
||||
[[bin]]
|
||||
name = "compliance-dashboard"
|
||||
path = "../bin/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:reqwest", "dep:web-sys"]
|
||||
server = [
|
||||
"dioxus/server",
|
||||
"dioxus/router",
|
||||
"dioxus/fullstack",
|
||||
"dep:axum",
|
||||
"dep:mongodb",
|
||||
"dep:reqwest",
|
||||
"dep:tower-http",
|
||||
"dep:secrecy",
|
||||
"dep:dotenvy",
|
||||
"dep:dioxus-cli-config",
|
||||
"dep:dioxus-fullstack",
|
||||
"dep:tokio",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true }
|
||||
dioxus = "=0.7.3"
|
||||
dioxus-free-icons = { version = "0.10", features = ["bootstrap"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
dioxus-logger = "0.6"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Web-only
|
||||
reqwest = { workspace = true, optional = true }
|
||||
web-sys = { version = "0.3", optional = true }
|
||||
|
||||
# Server-only
|
||||
axum = { version = "0.8", optional = true }
|
||||
mongodb = { workspace = true, optional = true }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"], optional = true }
|
||||
secrecy = { workspace = true, optional = true }
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
dioxus-cli-config = { version = "=0.7.3", optional = true }
|
||||
dioxus-fullstack = { version = "=0.7.3", optional = true }
|
||||
28
compliance-dashboard/assets/favicon.svg
Normal file
28
compliance-dashboard/assets/favicon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f172a"/>
|
||||
<stop offset="100%" stop-color="#1e293b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shield" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#38bdf8"/>
|
||||
<stop offset="100%" stop-color="#818cf8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="18" fill="url(#bg)"/>
|
||||
<!-- Shield outline -->
|
||||
<path d="M48 14 L28 26 L28 48 C28 62 37 74 48 78 C59 74 68 62 68 48 L68 26 Z"
|
||||
fill="none" stroke="url(#shield)" stroke-width="3" stroke-linejoin="round"/>
|
||||
<!-- Inner shield fill (subtle) -->
|
||||
<path d="M48 18 L31 28.5 L31 47 C31 59.5 39 70 48 74 C57 70 65 59.5 65 47 L65 28.5 Z"
|
||||
fill="url(#shield)" opacity="0.1"/>
|
||||
<!-- Magnifying glass -->
|
||||
<circle cx="45" cy="44" r="10" fill="none" stroke="#38bdf8" stroke-width="2.5"/>
|
||||
<line x1="52" y1="51" x2="60" y2="59" stroke="#38bdf8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- Checkmark inside magnifier -->
|
||||
<path d="M40 44 L43.5 47.5 L50 41" fill="none" stroke="#22c55e" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Scan lines (decorative) -->
|
||||
<line x1="34" y1="32" x2="46" y2="32" stroke="#38bdf8" stroke-width="1.5" opacity="0.4" stroke-linecap="round"/>
|
||||
<line x1="34" y1="36" x2="42" y2="36" stroke="#38bdf8" stroke-width="1.5" opacity="0.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
315
compliance-dashboard/assets/main.css
Normal file
315
compliance-dashboard/assets/main.css
Normal file
@@ -0,0 +1,315 @@
|
||||
:root {
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 56px;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #7dd3fc;
|
||||
--border: #334155;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--success: #22c55e;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: rgba(56, 189, 248, 0.05);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-critical { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||
.badge-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; }
|
||||
.badge-medium { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
.badge-low { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||
.badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
1
compliance-dashboard/assets/tailwind.css
Normal file
1
compliance-dashboard/assets/tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
/* Placeholder - generated by build.rs via bunx @tailwindcss/cli */
|
||||
38
compliance-dashboard/src/app.rs
Normal file
38
compliance-dashboard/src/app.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::app_shell::AppShell;
|
||||
use crate::pages::*;
|
||||
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(AppShell)]
|
||||
#[route("/")]
|
||||
OverviewPage {},
|
||||
#[route("/repositories")]
|
||||
RepositoriesPage {},
|
||||
#[route("/findings")]
|
||||
FindingsPage {},
|
||||
#[route("/findings/:id")]
|
||||
FindingDetailPage { id: String },
|
||||
#[route("/sbom")]
|
||||
SbomPage {},
|
||||
#[route("/issues")]
|
||||
IssuesPage {},
|
||||
#[route("/settings")]
|
||||
SettingsPage {},
|
||||
}
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
document::Link { rel: "icon", href: FAVICON }
|
||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
16
compliance-dashboard/src/components/app_shell.rs
Normal file
16
compliance-dashboard/src/components/app_shell.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sidebar::Sidebar;
|
||||
|
||||
#[component]
|
||||
pub fn AppShell() -> Element {
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {}
|
||||
main { class: "main-content",
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
compliance-dashboard/src/components/code_snippet.rs
Normal file
23
compliance-dashboard/src/components/code_snippet.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn CodeSnippet(
|
||||
code: String,
|
||||
#[props(default)] file_path: String,
|
||||
#[props(default)] line_number: u32,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
if !file_path.is_empty() {
|
||||
div {
|
||||
style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; font-family: monospace;",
|
||||
"{file_path}"
|
||||
if line_number > 0 {
|
||||
":{line_number}"
|
||||
}
|
||||
}
|
||||
}
|
||||
pre { class: "code-block", "{code}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
7
compliance-dashboard/src/components/mod.rs
Normal file
7
compliance-dashboard/src/components/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod app_shell;
|
||||
pub mod code_snippet;
|
||||
pub mod page_header;
|
||||
pub mod pagination;
|
||||
pub mod severity_badge;
|
||||
pub mod sidebar;
|
||||
pub mod stat_card;
|
||||
13
compliance-dashboard/src/components/page_header.rs
Normal file
13
compliance-dashboard/src/components/page_header.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn PageHeader(title: String, #[props(default)] description: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "page-header",
|
||||
h2 { "{title}" }
|
||||
if !description.is_empty() {
|
||||
p { "{description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
compliance-dashboard/src/components/pagination.rs
Normal file
33
compliance-dashboard/src/components/pagination.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Pagination(
|
||||
current_page: u64,
|
||||
total_pages: u64,
|
||||
on_page_change: EventHandler<u64>,
|
||||
) -> Element {
|
||||
if total_pages <= 1 {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| on_page_change.call(current_page.saturating_sub(1)),
|
||||
"Previous"
|
||||
}
|
||||
span {
|
||||
style: "color: var(--text-secondary); font-size: 14px;",
|
||||
"Page {current_page} of {total_pages}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
disabled: current_page >= total_pages,
|
||||
onclick: move |_| on_page_change.call(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
compliance-dashboard/src/components/severity_badge.rs
Normal file
16
compliance-dashboard/src/components/severity_badge.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn SeverityBadge(severity: String) -> Element {
|
||||
let class = match severity.to_lowercase().as_str() {
|
||||
"critical" => "badge badge-critical",
|
||||
"high" => "badge badge-high",
|
||||
"medium" => "badge badge-medium",
|
||||
"low" => "badge badge-low",
|
||||
_ => "badge badge-info",
|
||||
};
|
||||
|
||||
rsx! {
|
||||
span { class: class, "{severity}" }
|
||||
}
|
||||
}
|
||||
81
compliance-dashboard/src/components/sidebar.rs
Normal file
81
compliance-dashboard/src/components/sidebar.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
|
||||
struct NavItem {
|
||||
label: &'static str,
|
||||
route: Route,
|
||||
icon: Element,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> Element {
|
||||
let current_route = use_route::<Route>();
|
||||
|
||||
let nav_items = [
|
||||
NavItem {
|
||||
label: "Overview",
|
||||
route: Route::OverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsSpeedometer2, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Repositories",
|
||||
route: Route::RepositoriesPage {},
|
||||
icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Findings",
|
||||
route: Route::FindingsPage {},
|
||||
icon: rsx! { Icon { icon: BsShieldExclamation, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "SBOM",
|
||||
route: Route::SbomPage {},
|
||||
icon: rsx! { Icon { icon: BsBoxSeam, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Issues",
|
||||
route: Route::IssuesPage {},
|
||||
icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Settings",
|
||||
route: Route::SettingsPage {},
|
||||
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
nav { class: "sidebar",
|
||||
div { class: "sidebar-header",
|
||||
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||
h1 { "Compliance Scanner" }
|
||||
}
|
||||
div { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
{
|
||||
let is_active = match (¤t_route, &item.route) {
|
||||
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
|
||||
(a, b) => a == b,
|
||||
};
|
||||
let class = if is_active { "nav-item active" } else { "nav-item" };
|
||||
rsx! {
|
||||
Link {
|
||||
to: item.route.clone(),
|
||||
class: class,
|
||||
{item.icon}
|
||||
span { "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
style: "padding: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);",
|
||||
"v0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
compliance-dashboard/src/components/stat_card.rs
Normal file
21
compliance-dashboard/src/components/stat_card.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StatCard(
|
||||
label: String,
|
||||
value: String,
|
||||
#[props(default)] color: String,
|
||||
) -> Element {
|
||||
let value_style = if color.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("color: {color}")
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "stat-card",
|
||||
div { class: "label", "{label}" }
|
||||
div { class: "value", style: value_style, "{value}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
18
compliance-dashboard/src/infrastructure/config.rs
Normal file
18
compliance-dashboard/src/infrastructure/config.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use compliance_core::DashboardConfig;
|
||||
|
||||
use super::error::DashboardError;
|
||||
|
||||
pub fn load_config() -> Result<DashboardConfig, DashboardError> {
|
||||
Ok(DashboardConfig {
|
||||
mongodb_uri: std::env::var("MONGODB_URI")
|
||||
.map_err(|_| DashboardError::Config("Missing MONGODB_URI".to_string()))?,
|
||||
mongodb_database: std::env::var("MONGODB_DATABASE")
|
||||
.unwrap_or_else(|_| "compliance_scanner".to_string()),
|
||||
agent_api_url: std::env::var("AGENT_API_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3001".to_string()),
|
||||
dashboard_port: std::env::var("DASHBOARD_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
})
|
||||
}
|
||||
45
compliance-dashboard/src/infrastructure/database.rs
Normal file
45
compliance-dashboard/src/infrastructure/database.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::{Client, Collection};
|
||||
|
||||
use compliance_core::models::*;
|
||||
|
||||
use super::error::DashboardError;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, DashboardError> {
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
let db = client.database(db_name);
|
||||
db.run_command(doc! { "ping": 1 }).await?;
|
||||
tracing::info!("Dashboard connected to MongoDB '{db_name}'");
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
pub fn repositories(&self) -> Collection<TrackedRepository> {
|
||||
self.inner.collection("repositories")
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
self.inner.collection("findings")
|
||||
}
|
||||
|
||||
pub fn scan_runs(&self) -> Collection<ScanRun> {
|
||||
self.inner.collection("scan_runs")
|
||||
}
|
||||
|
||||
pub fn sbom_entries(&self) -> Collection<SbomEntry> {
|
||||
self.inner.collection("sbom_entries")
|
||||
}
|
||||
|
||||
pub fn cve_alerts(&self) -> Collection<CveAlert> {
|
||||
self.inner.collection("cve_alerts")
|
||||
}
|
||||
|
||||
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
||||
self.inner.collection("tracker_issues")
|
||||
}
|
||||
}
|
||||
26
compliance-dashboard/src/infrastructure/error.rs
Normal file
26
compliance-dashboard/src/infrastructure/error.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use dioxus::prelude::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DashboardError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] mongodb::error::Error),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DashboardError> for ServerFnError {
|
||||
fn from(err: DashboardError) -> Self {
|
||||
ServerFnError::new(err.to_string())
|
||||
}
|
||||
}
|
||||
71
compliance-dashboard/src/infrastructure/findings.rs
Normal file
71
compliance-dashboard/src/infrastructure/findings.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::Finding;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct FindingsListResponse {
|
||||
pub data: Vec<Finding>,
|
||||
pub total: Option<u64>,
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_findings(
|
||||
page: u64,
|
||||
severity: String,
|
||||
scan_type: String,
|
||||
status: String,
|
||||
repo_id: String,
|
||||
) -> Result<FindingsListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let mut url = format!("{}/api/v1/findings?page={page}&limit=20", state.agent_api_url);
|
||||
if !severity.is_empty() {
|
||||
url.push_str(&format!("&severity={severity}"));
|
||||
}
|
||||
if !scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={scan_type}"));
|
||||
}
|
||||
if !status.is_empty() {
|
||||
url.push_str(&format!("&status={status}"));
|
||||
}
|
||||
if !repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={repo_id}"));
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: FindingsListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_finding_detail(id: String) -> Result<Finding, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/{id}", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let finding: Finding = serde_json::from_value(body["data"].clone())
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(finding)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn update_finding_status(id: String, status: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/{id}/status", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "status": status }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
22
compliance-dashboard/src/infrastructure/issues.rs
Normal file
22
compliance-dashboard/src/infrastructure/issues.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::TrackerIssue;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct IssuesListResponse {
|
||||
pub data: Vec<TrackerIssue>,
|
||||
pub total: Option<u64>,
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_issues(page: u64) -> Result<IssuesListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/issues?page={page}&limit=20", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: IssuesListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
13
compliance-dashboard/src/infrastructure/mod.rs
Normal file
13
compliance-dashboard/src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod config;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod findings;
|
||||
pub mod issues;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod scans;
|
||||
pub mod server;
|
||||
pub mod server_state;
|
||||
pub mod stats;
|
||||
|
||||
pub use server::server_start;
|
||||
64
compliance-dashboard/src/infrastructure/repositories.rs
Normal file
64
compliance-dashboard/src/infrastructure/repositories.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::TrackedRepository;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RepositoryListResponse {
|
||||
pub data: Vec<TrackedRepository>,
|
||||
pub total: Option<u64>,
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_repositories(page: u64) -> Result<RepositoryListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/repositories?page={page}&limit=20", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: RepositoryListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn add_repository(name: String, git_url: String, default_branch: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/repositories", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Failed to add repository: {body}")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/repositories/{repo_id}/scan", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
22
compliance-dashboard/src/infrastructure/sbom.rs
Normal file
22
compliance-dashboard/src/infrastructure/sbom.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::SbomEntry;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SbomListResponse {
|
||||
pub data: Vec<SbomEntry>,
|
||||
pub total: Option<u64>,
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_sbom(page: u64) -> Result<SbomListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: SbomListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
22
compliance-dashboard/src/infrastructure/scans.rs
Normal file
22
compliance-dashboard/src/infrastructure/scans.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::ScanRun;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ScansListResponse {
|
||||
pub data: Vec<ScanRun>,
|
||||
pub total: Option<u64>,
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_scan_runs(page: u64) -> Result<ScansListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/scan-runs?page={page}&limit=20", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: ScansListResponse = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
41
compliance-dashboard/src/infrastructure/server.rs
Normal file
41
compliance-dashboard/src/infrastructure/server.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use super::config;
|
||||
use super::database::Database;
|
||||
use super::error::DashboardError;
|
||||
use super::server_state::{ServerState, ServerStateInner};
|
||||
|
||||
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
tokio::runtime::Runtime::new()
|
||||
.map_err(|e| DashboardError::Other(e.to_string()))?
|
||||
.block_on(async move {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let config = config::load_config()?;
|
||||
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
|
||||
let server_state: ServerState = ServerStateInner {
|
||||
agent_api_url: config.agent_api_url.clone(),
|
||||
db,
|
||||
config,
|
||||
}
|
||||
.into();
|
||||
|
||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("Failed to bind: {e}")))?;
|
||||
|
||||
tracing::info!("Dashboard server listening on {addr}");
|
||||
|
||||
let router = axum::Router::new()
|
||||
.serve_dioxus_application(ServeConfig::new(), app)
|
||||
.layer(axum::Extension(server_state));
|
||||
|
||||
axum::serve(listener, router.into_make_service())
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("Server error: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
46
compliance-dashboard/src/infrastructure/server_state.rs
Normal file
46
compliance-dashboard/src/infrastructure/server_state.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use compliance_core::DashboardConfig;
|
||||
|
||||
use super::database::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState(Arc<ServerStateInner>);
|
||||
|
||||
impl Deref for ServerState {
|
||||
type Target = ServerStateInner;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServerStateInner {
|
||||
pub db: Database,
|
||||
pub config: DashboardConfig,
|
||||
pub agent_api_url: String,
|
||||
}
|
||||
|
||||
impl From<ServerStateInner> for ServerState {
|
||||
fn from(inner: ServerStateInner) -> Self {
|
||||
Self(Arc::new(inner))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> axum::extract::FromRequestParts<S> for ServerState
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = axum::http::StatusCode;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<ServerState>()
|
||||
.cloned()
|
||||
.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
27
compliance-dashboard/src/infrastructure/stats.rs
Normal file
27
compliance-dashboard/src/infrastructure/stats.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OverviewStats {
|
||||
pub total_repositories: u64,
|
||||
pub total_findings: u64,
|
||||
pub critical_findings: u64,
|
||||
pub high_findings: u64,
|
||||
pub medium_findings: u64,
|
||||
pub low_findings: u64,
|
||||
pub total_sbom_entries: u64,
|
||||
pub total_cve_alerts: u64,
|
||||
pub total_issues: u64,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_overview_stats() -> Result<OverviewStats, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/stats/overview", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: serde_json::Value = resp.json().await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let stats: OverviewStats = serde_json::from_value(body["data"].clone()).unwrap_or_default();
|
||||
Ok(stats)
|
||||
}
|
||||
8
compliance-dashboard/src/lib.rs
Normal file
8
compliance-dashboard/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod pages;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use app::App;
|
||||
117
compliance-dashboard/src/pages/finding_detail.rs
Normal file
117
compliance-dashboard/src/pages/finding_detail.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::code_snippet::CodeSnippet;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
|
||||
#[component]
|
||||
pub fn FindingDetailPage(id: String) -> Element {
|
||||
let finding_id = id.clone();
|
||||
|
||||
let finding = use_resource(move || {
|
||||
let fid = finding_id.clone();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_finding_detail(fid).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
let snapshot = finding.read().clone();
|
||||
|
||||
match snapshot {
|
||||
Some(Some(f)) => {
|
||||
let finding_id_for_status = id.clone();
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: f.title.clone(),
|
||||
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
||||
}
|
||||
|
||||
div { style: "display: flex; gap: 8px; margin-bottom: 16px;",
|
||||
SeverityBadge { severity: f.severity.to_string() }
|
||||
if let Some(cwe) = &f.cwe {
|
||||
span { class: "badge badge-info", "{cwe}" }
|
||||
}
|
||||
if let Some(cve) = &f.cve {
|
||||
span { class: "badge badge-high", "{cve}" }
|
||||
}
|
||||
if let Some(score) = f.cvss_score {
|
||||
span { class: "badge badge-medium", "CVSS: {score}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Description" }
|
||||
p { style: "line-height: 1.6;", "{f.description}" }
|
||||
}
|
||||
|
||||
if let Some(code) = &f.code_snippet {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Code Evidence" }
|
||||
CodeSnippet {
|
||||
code: code.clone(),
|
||||
file_path: f.file_path.clone().unwrap_or_default(),
|
||||
line_number: f.line_number.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(remediation) = &f.remediation {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Remediation" }
|
||||
p { style: "line-height: 1.6;", "{remediation}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(fix) = &f.suggested_fix {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Suggested Fix" }
|
||||
CodeSnippet { code: fix.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = &f.tracker_issue_url {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Linked Issue" }
|
||||
a {
|
||||
href: "{url}",
|
||||
target: "_blank",
|
||||
style: "color: var(--accent);",
|
||||
"{url}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Update Status" }
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
for status in ["open", "triaged", "resolved", "false_positive", "ignored"] {
|
||||
{
|
||||
let status_str = status.to_string();
|
||||
let id_clone = finding_id_for_status.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
let s = status_str.clone();
|
||||
let id = id_clone.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
||||
});
|
||||
},
|
||||
"{status}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Finding not found." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading finding..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
124
compliance-dashboard/src/pages/findings.rs
Normal file
124
compliance-dashboard/src/pages/findings.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
|
||||
#[component]
|
||||
pub fn FindingsPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
let mut severity_filter = use_signal(String::new);
|
||||
let mut type_filter = use_signal(String::new);
|
||||
let mut status_filter = use_signal(String::new);
|
||||
|
||||
let findings = use_resource(move || {
|
||||
let p = page();
|
||||
let sev = severity_filter();
|
||||
let typ = type_filter();
|
||||
let stat = status_filter();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, String::new()).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Findings",
|
||||
description: "Security and compliance findings across all repositories",
|
||||
}
|
||||
|
||||
div { class: "filter-bar",
|
||||
select {
|
||||
onchange: move |e| { severity_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Severities" }
|
||||
option { value: "critical", "Critical" }
|
||||
option { value: "high", "High" }
|
||||
option { value: "medium", "Medium" }
|
||||
option { value: "low", "Low" }
|
||||
option { value: "info", "Info" }
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { type_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Types" }
|
||||
option { value: "sast", "SAST" }
|
||||
option { value: "sbom", "SBOM" }
|
||||
option { value: "cve", "CVE" }
|
||||
option { value: "gdpr", "GDPR" }
|
||||
option { value: "oauth", "OAuth" }
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Statuses" }
|
||||
option { value: "open", "Open" }
|
||||
option { value: "triaged", "Triaged" }
|
||||
option { value: "resolved", "Resolved" }
|
||||
option { value: "false_positive", "False Positive" }
|
||||
option { value: "ignored", "Ignored" }
|
||||
}
|
||||
}
|
||||
|
||||
match &*findings.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Severity" }
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "Scanner" }
|
||||
th { "File" }
|
||||
th { "Status" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in &resp.data {
|
||||
{
|
||||
let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||
rsx! {
|
||||
tr {
|
||||
td { SeverityBadge { severity: finding.severity.to_string() } }
|
||||
td {
|
||||
Link {
|
||||
to: Route::FindingDetailPage { id: id },
|
||||
style: "color: var(--accent); text-decoration: none;",
|
||||
"{finding.title}"
|
||||
}
|
||||
}
|
||||
td { "{finding.scan_type}" }
|
||||
td { "{finding.scanner}" }
|
||||
td {
|
||||
style: "font-family: monospace; font-size: 12px;",
|
||||
"{finding.file_path.as_deref().unwrap_or(\"-\")}"
|
||||
}
|
||||
td {
|
||||
span { class: "badge badge-info", "{finding.status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pagination {
|
||||
current_page: page(),
|
||||
total_pages: total_pages,
|
||||
on_page_change: move |p| page.set(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load findings." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading findings..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
87
compliance-dashboard/src/pages/issues.rs
Normal file
87
compliance-dashboard/src/pages/issues.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
|
||||
#[component]
|
||||
pub fn IssuesPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
|
||||
let issues = use_resource(move || {
|
||||
let p = page();
|
||||
async move {
|
||||
crate::infrastructure::issues::fetch_issues(p).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Issues",
|
||||
description: "Cross-tracker issue view - GitHub, GitLab, and Jira",
|
||||
}
|
||||
|
||||
match &*issues.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Tracker" }
|
||||
th { "ID" }
|
||||
th { "Title" }
|
||||
th { "Status" }
|
||||
th { "Created" }
|
||||
th { "Link" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for issue in &resp.data {
|
||||
tr {
|
||||
td {
|
||||
span { class: "badge badge-info", "{issue.tracker_type}" }
|
||||
}
|
||||
td {
|
||||
style: "font-family: monospace;",
|
||||
"{issue.external_id}"
|
||||
}
|
||||
td { "{issue.title}" }
|
||||
td {
|
||||
span { class: "badge badge-info", "{issue.status}" }
|
||||
}
|
||||
td {
|
||||
style: "font-size: 12px; color: var(--text-secondary);",
|
||||
{issue.created_at.format("%Y-%m-%d %H:%M").to_string()}
|
||||
}
|
||||
td {
|
||||
a {
|
||||
href: "{issue.external_url}",
|
||||
target: "_blank",
|
||||
style: "color: var(--accent); text-decoration: none;",
|
||||
"Open"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pagination {
|
||||
current_page: page(),
|
||||
total_pages: total_pages,
|
||||
on_page_change: move |p| page.set(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load issues." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading issues..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
15
compliance-dashboard/src/pages/mod.rs
Normal file
15
compliance-dashboard/src/pages/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod finding_detail;
|
||||
pub mod findings;
|
||||
pub mod issues;
|
||||
pub mod overview;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod settings;
|
||||
|
||||
pub use finding_detail::FindingDetailPage;
|
||||
pub use findings::FindingsPage;
|
||||
pub use issues::IssuesPage;
|
||||
pub use overview::OverviewPage;
|
||||
pub use repositories::RepositoriesPage;
|
||||
pub use sbom::SbomPage;
|
||||
pub use settings::SettingsPage;
|
||||
104
compliance-dashboard/src/pages/overview.rs
Normal file
104
compliance-dashboard/src/pages/overview.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::stat_card::StatCard;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::infrastructure::stats::fetch_overview_stats;
|
||||
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
let stats = use_resource(move || async move {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
fetch_overview_stats().await.ok()
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
crate::infrastructure::stats::fetch_overview_stats().await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Overview",
|
||||
description: "Security and compliance scanning dashboard",
|
||||
}
|
||||
|
||||
match &*stats.read() {
|
||||
Some(Some(s)) => rsx! {
|
||||
div { class: "stat-cards",
|
||||
StatCard { label: "Repositories", value: s.total_repositories.to_string() }
|
||||
StatCard { label: "Total Findings", value: s.total_findings.to_string() }
|
||||
StatCard {
|
||||
label: "Critical",
|
||||
value: s.critical_findings.to_string(),
|
||||
color: "var(--danger)",
|
||||
}
|
||||
StatCard {
|
||||
label: "High",
|
||||
value: s.high_findings.to_string(),
|
||||
color: "#f97316",
|
||||
}
|
||||
StatCard {
|
||||
label: "Medium",
|
||||
value: s.medium_findings.to_string(),
|
||||
color: "var(--warning)",
|
||||
}
|
||||
StatCard {
|
||||
label: "Low",
|
||||
value: s.low_findings.to_string(),
|
||||
color: "var(--success)",
|
||||
}
|
||||
StatCard { label: "Dependencies", value: s.total_sbom_entries.to_string() }
|
||||
StatCard { label: "CVE Alerts", value: s.total_cve_alerts.to_string() }
|
||||
StatCard { label: "Tracker Issues", value: s.total_issues.to_string() }
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Severity Distribution" }
|
||||
div {
|
||||
style: "display: flex; gap: 8px; align-items: flex-end; height: 200px; padding: 16px;",
|
||||
SeverityBar { label: "Critical", count: s.critical_findings, max: s.total_findings, color: "var(--danger)" }
|
||||
SeverityBar { label: "High", count: s.high_findings, max: s.total_findings, color: "#f97316" }
|
||||
SeverityBar { label: "Medium", count: s.medium_findings, max: s.total_findings, color: "var(--warning)" }
|
||||
SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card",
|
||||
p { style: "color: var(--text-secondary);",
|
||||
"Unable to load stats. Make sure the agent API is running."
|
||||
}
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading overview..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SeverityBar(label: String, count: u64, max: u64, color: String) -> Element {
|
||||
let height_pct = if max > 0 { (count as f64 / max as f64) * 100.0 } else { 0.0 };
|
||||
let height = format!("{}%", height_pct.max(2.0));
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
style: "flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;",
|
||||
div {
|
||||
style: "font-size: 14px; font-weight: 600;",
|
||||
"{count}"
|
||||
}
|
||||
div {
|
||||
style: "width: 100%; background: {color}; border-radius: 4px 4px 0 0; height: {height}; min-height: 4px; transition: height 0.3s;",
|
||||
}
|
||||
div {
|
||||
style: "font-size: 11px; color: var(--text-secondary);",
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
compliance-dashboard/src/pages/repositories.rs
Normal file
155
compliance-dashboard/src/pages/repositories.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
|
||||
#[component]
|
||||
pub fn RepositoriesPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
let mut show_add_form = use_signal(|| false);
|
||||
let mut name = use_signal(String::new);
|
||||
let mut git_url = use_signal(String::new);
|
||||
let mut branch = use_signal(|| "main".to_string());
|
||||
|
||||
let repos = use_resource(move || {
|
||||
let p = page();
|
||||
async move {
|
||||
crate::infrastructure::repositories::fetch_repositories(p).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Repositories",
|
||||
description: "Tracked git repositories",
|
||||
}
|
||||
|
||||
div { style: "margin-bottom: 16px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| show_add_form.toggle(),
|
||||
if show_add_form() { "Cancel" } else { "+ Add Repository" }
|
||||
}
|
||||
}
|
||||
|
||||
if show_add_form() {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Add Repository" }
|
||||
div { class: "form-group",
|
||||
label { "Name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "my-project",
|
||||
value: "{name}",
|
||||
oninput: move |e| name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Git URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "https://github.com/org/repo.git",
|
||||
value: "{git_url}",
|
||||
oninput: move |e| git_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Default Branch" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "main",
|
||||
value: "{branch}",
|
||||
oninput: move |e| branch.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
let n = name();
|
||||
let u = git_url();
|
||||
let b = branch();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::repositories::add_repository(n, u, b).await;
|
||||
});
|
||||
show_add_form.set(false);
|
||||
name.set(String::new());
|
||||
git_url.set(String::new());
|
||||
},
|
||||
"Add"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &*repos.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Git URL" }
|
||||
th { "Branch" }
|
||||
th { "Findings" }
|
||||
th { "Last Scanned" }
|
||||
th { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for repo in &resp.data {
|
||||
{
|
||||
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||
let repo_id_clone = repo_id.clone();
|
||||
rsx! {
|
||||
tr {
|
||||
td { "{repo.name}" }
|
||||
td {
|
||||
style: "font-size: 12px; font-family: monospace;",
|
||||
"{repo.git_url}"
|
||||
}
|
||||
td { "{repo.default_branch}" }
|
||||
td { "{repo.findings_count}" }
|
||||
td {
|
||||
match &repo.last_scanned_commit {
|
||||
Some(sha) => rsx! { span { style: "font-family: monospace; font-size: 12px;", "{&sha[..7.min(sha.len())]}" } },
|
||||
None => rsx! { span { style: "color: var(--text-secondary);", "Never" } },
|
||||
}
|
||||
}
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
let id = repo_id_clone.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::repositories::trigger_repo_scan(id).await;
|
||||
});
|
||||
},
|
||||
"Scan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pagination {
|
||||
current_page: page(),
|
||||
total_pages: total_pages,
|
||||
on_page_change: move |p| page.set(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load repositories." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading repositories..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
85
compliance-dashboard/src/pages/sbom.rs
Normal file
85
compliance-dashboard/src/pages/sbom.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
|
||||
#[component]
|
||||
pub fn SbomPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
|
||||
let sbom = use_resource(move || {
|
||||
let p = page();
|
||||
async move {
|
||||
crate::infrastructure::sbom::fetch_sbom(p).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "SBOM",
|
||||
description: "Software Bill of Materials - dependency inventory across all repositories",
|
||||
}
|
||||
|
||||
match &*sbom.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Package" }
|
||||
th { "Version" }
|
||||
th { "Manager" }
|
||||
th { "License" }
|
||||
th { "Vulnerabilities" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for entry in &resp.data {
|
||||
tr {
|
||||
td {
|
||||
style: "font-weight: 500;",
|
||||
"{entry.name}"
|
||||
}
|
||||
td {
|
||||
style: "font-family: monospace; font-size: 13px;",
|
||||
"{entry.version}"
|
||||
}
|
||||
td { "{entry.package_manager}" }
|
||||
td { "{entry.license.as_deref().unwrap_or(\"-\")}" }
|
||||
td {
|
||||
if entry.known_vulnerabilities.is_empty() {
|
||||
span {
|
||||
style: "color: var(--success);",
|
||||
"None"
|
||||
}
|
||||
} else {
|
||||
span { class: "badge badge-high",
|
||||
"{entry.known_vulnerabilities.len()} vuln(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Pagination {
|
||||
current_page: page(),
|
||||
total_pages: total_pages,
|
||||
on_page_change: move |p| page.set(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load SBOM." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading SBOM..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
142
compliance-dashboard/src/pages/settings.rs
Normal file
142
compliance-dashboard/src/pages/settings.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
|
||||
#[component]
|
||||
pub fn SettingsPage() -> Element {
|
||||
let mut litellm_url = use_signal(|| "http://localhost:4000".to_string());
|
||||
let mut litellm_model = use_signal(|| "gpt-4o".to_string());
|
||||
let mut github_token = use_signal(String::new);
|
||||
let mut gitlab_url = use_signal(|| "https://gitlab.com".to_string());
|
||||
let mut gitlab_token = use_signal(String::new);
|
||||
let mut jira_url = use_signal(String::new);
|
||||
let mut jira_email = use_signal(String::new);
|
||||
let mut jira_token = use_signal(String::new);
|
||||
let mut jira_project = use_signal(String::new);
|
||||
let mut searxng_url = use_signal(|| "http://localhost:8888".to_string());
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Settings",
|
||||
description: "Configure integrations and scanning parameters",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "LiteLLM Configuration" }
|
||||
div { class: "form-group",
|
||||
label { "LiteLLM URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{litellm_url}",
|
||||
oninput: move |e| litellm_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Model" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{litellm_model}",
|
||||
oninput: move |e| litellm_model.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "GitHub Integration" }
|
||||
div { class: "form-group",
|
||||
label { "Personal Access Token" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "ghp_...",
|
||||
value: "{github_token}",
|
||||
oninput: move |e| github_token.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "GitLab Integration" }
|
||||
div { class: "form-group",
|
||||
label { "GitLab URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{gitlab_url}",
|
||||
oninput: move |e| gitlab_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Access Token" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "glpat-...",
|
||||
value: "{gitlab_token}",
|
||||
oninput: move |e| gitlab_token.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Jira Integration" }
|
||||
div { class: "form-group",
|
||||
label { "Jira URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "https://your-org.atlassian.net",
|
||||
value: "{jira_url}",
|
||||
oninput: move |e| jira_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Email" }
|
||||
input {
|
||||
r#type: "email",
|
||||
value: "{jira_email}",
|
||||
oninput: move |e| jira_email.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "API Token" }
|
||||
input {
|
||||
r#type: "password",
|
||||
value: "{jira_token}",
|
||||
oninput: move |e| jira_token.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Project Key" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "SEC",
|
||||
value: "{jira_project}",
|
||||
oninput: move |e| jira_project.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "SearXNG" }
|
||||
div { class: "form-group",
|
||||
label { "SearXNG URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{searxng_url}",
|
||||
oninput: move |e| searxng_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "margin-top: 16px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
tracing::info!("Settings save not yet implemented - settings are managed via .env");
|
||||
},
|
||||
"Save Settings"
|
||||
}
|
||||
p {
|
||||
style: "margin-top: 8px; font-size: 12px; color: var(--text-secondary);",
|
||||
"Note: Settings are currently configured via environment variables (.env file). Dashboard-based settings persistence coming soon."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user