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:
Sharang Parnerkar
2026-03-02 13:30:17 +01:00
commit 0867e401bc
97 changed files with 11750 additions and 0 deletions

View 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 }

View 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

View 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;
}
}

View File

@@ -0,0 +1 @@
/* Placeholder - generated by build.rs via bunx @tailwindcss/cli */

View 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> {}
}
}

View 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> {}
}
}
}
}

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

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

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

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

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

View 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 (&current_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"
}
}
}
}

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

View 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),
})
}

View 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")
}
}

View 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())
}
}

View 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(())
}

View 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)
}

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

View 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(())
}

View 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)
}

View 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)
}

View 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(())
})
}

View 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)
}
}

View 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)
}

View File

@@ -0,0 +1,8 @@
pub mod app;
pub mod components;
pub mod pages;
#[cfg(feature = "server")]
pub mod infrastructure;
pub use app::App;

View 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..." }
},
}
}

View 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..." }
},
}
}
}

View 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..." }
},
}
}
}

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

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

View 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..." }
},
}
}
}

View 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..." }
},
}
}
}

View 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."
}
}
}
}