feat: findings refinement, new scanners, and deployment tooling (#6)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 2s
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 2s
This commit was merged in pull request #6.
This commit is contained in:
@@ -609,6 +609,24 @@ tbody tr:last-child td {
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.btn-scanning {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-bright);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
|
||||
@@ -14,5 +14,8 @@ pub fn load_config() -> Result<DashboardConfig, DashboardError> {
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,32 +10,50 @@ pub struct FindingsListResponse {
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct FindingsQuery {
|
||||
pub page: u64,
|
||||
pub severity: String,
|
||||
pub scan_type: String,
|
||||
pub status: String,
|
||||
pub repo_id: String,
|
||||
pub q: String,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_findings(
|
||||
page: u64,
|
||||
severity: String,
|
||||
scan_type: String,
|
||||
status: String,
|
||||
repo_id: String,
|
||||
) -> Result<FindingsListResponse, ServerFnError> {
|
||||
pub async fn fetch_findings(query: FindingsQuery) -> 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
|
||||
"{}/api/v1/findings?page={}&limit=20",
|
||||
state.agent_api_url, query.page
|
||||
);
|
||||
if !severity.is_empty() {
|
||||
url.push_str(&format!("&severity={severity}"));
|
||||
if !query.severity.is_empty() {
|
||||
url.push_str(&format!("&severity={}", query.severity));
|
||||
}
|
||||
if !scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={scan_type}"));
|
||||
if !query.scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={}", query.scan_type));
|
||||
}
|
||||
if !status.is_empty() {
|
||||
url.push_str(&format!("&status={status}"));
|
||||
if !query.status.is_empty() {
|
||||
url.push_str(&format!("&status={}", query.status));
|
||||
}
|
||||
if !repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={repo_id}"));
|
||||
if !query.repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={}", query.repo_id));
|
||||
}
|
||||
if !query.q.is_empty() {
|
||||
url.push_str(&format!(
|
||||
"&q={}",
|
||||
url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::<String>()
|
||||
));
|
||||
}
|
||||
if !query.sort_by.is_empty() {
|
||||
url.push_str(&format!("&sort_by={}", query.sort_by));
|
||||
}
|
||||
if !query.sort_order.is_empty() {
|
||||
url.push_str(&format!("&sort_order={}", query.sort_order));
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
@@ -82,3 +100,40 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn bulk_update_finding_status(
|
||||
ids: Vec<String>,
|
||||
status: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "ids": ids, "status": status }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "feedback": feedback }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,19 +34,29 @@ pub async fn add_repository(
|
||||
name: String,
|
||||
git_url: String,
|
||||
default_branch: String,
|
||||
auth_token: Option<String>,
|
||||
auth_username: Option<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 mut body = serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
});
|
||||
if let Some(token) = auth_token.filter(|t| !t.is_empty()) {
|
||||
body["auth_token"] = serde_json::Value::String(token);
|
||||
}
|
||||
if let Some(username) = auth_username.filter(|u| !u.is_empty()) {
|
||||
body["auth_username"] = serde_json::Value::String(username);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
}))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
@@ -61,6 +71,32 @@ pub async fn add_repository(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url);
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ServerFnError::new("SSH key not available".to_string()));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(body
|
||||
.get("public_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
@@ -99,3 +135,32 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a repository has any running scans
|
||||
#[server]
|
||||
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/scan-runs?page=1&limit=1", 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()))?;
|
||||
|
||||
// Check if the most recent scan for this repo is still running
|
||||
if let Some(scans) = body.get("data").and_then(|d| d.as_array()) {
|
||||
for scan in scans {
|
||||
let scan_repo = scan.get("repo_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = scan.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if scan_repo == repo_id && status == "running" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ use dioxus::prelude::*;
|
||||
use time::Duration;
|
||||
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||
|
||||
use compliance_core::models::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::config;
|
||||
use super::database::Database;
|
||||
use super::error::DashboardError;
|
||||
@@ -22,6 +25,9 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
|
||||
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
|
||||
// Seed default MCP server configs
|
||||
seed_default_mcp_servers(&db, config.mcp_endpoint_url.as_deref()).await;
|
||||
|
||||
if let Some(kc) = keycloak {
|
||||
tracing::info!("Keycloak configured for realm '{}'", kc.realm);
|
||||
} else {
|
||||
@@ -70,3 +76,66 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed three default MCP server configs (Findings, SBOM, DAST) if they don't already exist.
|
||||
async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) {
|
||||
let endpoint = mcp_endpoint_url.unwrap_or("http://localhost:8090");
|
||||
|
||||
let defaults = [
|
||||
(
|
||||
"Findings MCP",
|
||||
"Exposes security findings, triage data, and finding summaries to LLM agents",
|
||||
vec!["list_findings", "get_finding", "findings_summary"],
|
||||
),
|
||||
(
|
||||
"SBOM MCP",
|
||||
"Exposes software bill of materials and vulnerability reports to LLM agents",
|
||||
vec!["list_sbom_packages", "sbom_vuln_report"],
|
||||
),
|
||||
(
|
||||
"DAST MCP",
|
||||
"Exposes DAST scan findings and scan summaries to LLM agents",
|
||||
vec!["list_dast_findings", "dast_scan_summary"],
|
||||
),
|
||||
];
|
||||
|
||||
let collection = db.mcp_servers();
|
||||
|
||||
for (name, description, tools) in defaults {
|
||||
// Skip if already exists
|
||||
let exists = collection
|
||||
.find_one(doc! { "name": name })
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let server = McpServerConfig {
|
||||
id: None,
|
||||
name: name.to_string(),
|
||||
endpoint_url: format!("{endpoint}/mcp"),
|
||||
transport: McpTransport::Http,
|
||||
port: Some(8090),
|
||||
status: McpServerStatus::Stopped,
|
||||
access_token: token,
|
||||
tools_enabled: tools.into_iter().map(|s| s.to_string()).collect(),
|
||||
description: Some(description.to_string()),
|
||||
mongodb_uri: None,
|
||||
mongodb_database: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
match collection.insert_one(server).await {
|
||||
Ok(_) => tracing::info!("Seeded default MCP server: {name}"),
|
||||
Err(e) => tracing::warn!("Failed to seed MCP server '{name}': {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::components::severity_badge::SeverityBadge;
|
||||
pub fn FindingDetailPage(id: String) -> Element {
|
||||
let finding_id = id.clone();
|
||||
|
||||
let finding = use_resource(move || {
|
||||
let mut finding = use_resource(move || {
|
||||
let fid = finding_id.clone();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_finding_detail(fid)
|
||||
@@ -22,6 +22,8 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
match snapshot {
|
||||
Some(Some(f)) => {
|
||||
let finding_id_for_status = id.clone();
|
||||
let finding_id_for_feedback = id.clone();
|
||||
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: f.title.clone(),
|
||||
@@ -39,6 +41,9 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
if let Some(score) = f.cvss_score {
|
||||
span { class: "badge badge-medium", "CVSS: {score}" }
|
||||
}
|
||||
if let Some(confidence) = f.confidence {
|
||||
span { class: "badge badge-info", "Confidence: {confidence:.1}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
@@ -46,6 +51,19 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
p { "{f.description}" }
|
||||
}
|
||||
|
||||
if let Some(rationale) = &f.triage_rationale {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Triage Rationale" }
|
||||
div {
|
||||
style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;",
|
||||
if let Some(action) = &f.triage_action {
|
||||
span { class: "badge badge-info", "{action}" }
|
||||
}
|
||||
}
|
||||
p { style: "color: var(--text-secondary); font-size: 14px;", "{rationale}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = &f.code_snippet {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Code Evidence" }
|
||||
@@ -99,6 +117,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
||||
});
|
||||
finding.restart();
|
||||
},
|
||||
"{status}"
|
||||
}
|
||||
@@ -107,6 +126,25 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Developer Feedback" }
|
||||
p {
|
||||
style: "font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||
"Share your assessment of this finding (e.g. false positive, actionable, needs context)"
|
||||
}
|
||||
textarea {
|
||||
style: "width: 100%; min-height: 80px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 14px; resize: vertical;",
|
||||
value: "{existing_feedback}",
|
||||
oninput: move |e| {
|
||||
let feedback = e.value();
|
||||
let id = finding_id_for_feedback.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_feedback(id, feedback).await;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => rsx! {
|
||||
|
||||
@@ -12,6 +12,10 @@ pub fn FindingsPage() -> Element {
|
||||
let mut type_filter = use_signal(String::new);
|
||||
let mut status_filter = use_signal(String::new);
|
||||
let mut repo_filter = use_signal(String::new);
|
||||
let mut search_query = use_signal(String::new);
|
||||
let mut sort_by = use_signal(|| "created_at".to_string());
|
||||
let mut sort_order = use_signal(|| "desc".to_string());
|
||||
let mut selected_ids = use_signal(Vec::<String>::new);
|
||||
|
||||
let repos = use_resource(|| async {
|
||||
crate::infrastructure::repositories::fetch_repositories(1)
|
||||
@@ -19,19 +23,52 @@ pub fn FindingsPage() -> Element {
|
||||
.ok()
|
||||
});
|
||||
|
||||
let findings = use_resource(move || {
|
||||
let p = page();
|
||||
let sev = severity_filter();
|
||||
let typ = type_filter();
|
||||
let stat = status_filter();
|
||||
let repo = repo_filter();
|
||||
let mut findings = use_resource(move || {
|
||||
let query = crate::infrastructure::findings::FindingsQuery {
|
||||
page: page(),
|
||||
severity: severity_filter(),
|
||||
scan_type: type_filter(),
|
||||
status: status_filter(),
|
||||
repo_id: repo_filter(),
|
||||
q: search_query(),
|
||||
sort_by: sort_by(),
|
||||
sort_order: sort_order(),
|
||||
};
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo)
|
||||
crate::infrastructure::findings::fetch_findings(query)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_sort = move |field: &'static str| {
|
||||
move |_: MouseEvent| {
|
||||
if sort_by() == field {
|
||||
sort_order.set(if sort_order() == "asc" {
|
||||
"desc".to_string()
|
||||
} else {
|
||||
"asc".to_string()
|
||||
});
|
||||
} else {
|
||||
sort_by.set(field.to_string());
|
||||
sort_order.set("desc".to_string());
|
||||
}
|
||||
page.set(1);
|
||||
}
|
||||
};
|
||||
|
||||
let sort_indicator = move |field: &str| -> String {
|
||||
if sort_by() == field {
|
||||
if sort_order() == "asc" {
|
||||
" \u{25B2}".to_string()
|
||||
} else {
|
||||
" \u{25BC}".to_string()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Findings",
|
||||
@@ -39,6 +76,12 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
|
||||
div { class: "filter-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search findings...",
|
||||
style: "min-width: 200px;",
|
||||
oninput: move |e| { search_query.set(e.value()); page.set(1); },
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Repositories" }
|
||||
@@ -76,6 +119,9 @@ pub fn FindingsPage() -> Element {
|
||||
option { value: "cve", "CVE" }
|
||||
option { value: "gdpr", "GDPR" }
|
||||
option { value: "oauth", "OAuth" }
|
||||
option { value: "secret_detection", "Secrets" }
|
||||
option { value: "lint", "Lint" }
|
||||
option { value: "code_review", "Code Review" }
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
||||
@@ -88,29 +134,124 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk action bar
|
||||
if !selected_ids().is_empty() {
|
||||
div {
|
||||
class: "card",
|
||||
style: "display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 16px; background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.2);",
|
||||
span {
|
||||
style: "font-size: 14px; color: var(--text-secondary);",
|
||||
"{selected_ids().len()} selected"
|
||||
}
|
||||
for status in ["triaged", "resolved", "false_positive", "ignored"] {
|
||||
{
|
||||
let status_str = status.to_string();
|
||||
let label = match status {
|
||||
"false_positive" => "False Positive",
|
||||
other => {
|
||||
// Capitalize first letter
|
||||
let mut s = other.to_string();
|
||||
if let Some(c) = s.get_mut(0..1) { c.make_ascii_uppercase(); }
|
||||
// Leak to get a &str that lives long enough - this is fine for static-ish UI strings
|
||||
&*Box::leak(s.into_boxed_str())
|
||||
}
|
||||
};
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| {
|
||||
let ids = selected_ids();
|
||||
let s = status_str.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await;
|
||||
findings.restart();
|
||||
});
|
||||
selected_ids.set(Vec::new());
|
||||
},
|
||||
"Mark {label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| { selected_ids.set(Vec::new()); },
|
||||
"Clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &*findings.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||
let all_ids: Vec<String> = resp.data.iter().filter_map(|f| f.id.as_ref().map(|id| id.to_hex())).collect();
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Severity" }
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th {
|
||||
style: "width: 40px;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: !all_ids.is_empty() && selected_ids().len() == all_ids.len(),
|
||||
onchange: move |_| {
|
||||
if selected_ids().len() == all_ids.len() {
|
||||
selected_ids.set(Vec::new());
|
||||
} else {
|
||||
selected_ids.set(all_ids.clone());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("severity"),
|
||||
"Severity{sort_indicator(\"severity\")}"
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("title"),
|
||||
"Title{sort_indicator(\"title\")}"
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("scan_type"),
|
||||
"Type{sort_indicator(\"scan_type\")}"
|
||||
}
|
||||
th { "Scanner" }
|
||||
th { "File" }
|
||||
th { "Status" }
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("status"),
|
||||
"Status{sort_indicator(\"status\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in &resp.data {
|
||||
{
|
||||
let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||
let id_for_check = id.clone();
|
||||
let is_selected = selected_ids().contains(&id);
|
||||
rsx! {
|
||||
tr {
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_selected,
|
||||
onchange: move |_| {
|
||||
let mut ids = selected_ids();
|
||||
if ids.contains(&id_for_check) {
|
||||
ids.retain(|i| i != &id_for_check);
|
||||
} else {
|
||||
ids.push(id_for_check.clone());
|
||||
}
|
||||
selected_ids.set(ids);
|
||||
},
|
||||
}
|
||||
}
|
||||
td { SeverityBadge { severity: finding.severity.to_string() } }
|
||||
td {
|
||||
Link {
|
||||
|
||||
@@ -5,6 +5,17 @@ use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
|
||||
async fn async_sleep_5s() {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
gloo_timers::future::TimeoutFuture::new(5_000).await;
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RepositoriesPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
@@ -12,8 +23,15 @@ pub fn RepositoriesPage() -> Element {
|
||||
let mut name = use_signal(String::new);
|
||||
let mut git_url = use_signal(String::new);
|
||||
let mut branch = use_signal(|| "main".to_string());
|
||||
let mut auth_token = use_signal(String::new);
|
||||
let mut auth_username = use_signal(String::new);
|
||||
let mut show_auth = use_signal(|| false);
|
||||
let mut show_ssh_key = use_signal(|| false);
|
||||
let mut ssh_public_key = use_signal(String::new);
|
||||
let mut adding = use_signal(|| false);
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||
|
||||
let mut repos = use_resource(move || {
|
||||
let p = page();
|
||||
@@ -54,7 +72,7 @@ pub fn RepositoriesPage() -> Element {
|
||||
label { "Git URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "https://github.com/org/repo.git",
|
||||
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
||||
value: "{git_url}",
|
||||
oninput: move |e| git_url.set(e.value()),
|
||||
}
|
||||
@@ -68,26 +86,105 @@ pub fn RepositoriesPage() -> Element {
|
||||
oninput: move |e| branch.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
// Private repo auth section
|
||||
div { style: "margin-top: 8px;",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 12px; padding: 4px 8px;",
|
||||
onclick: move |_| {
|
||||
show_auth.toggle();
|
||||
if !show_ssh_key() {
|
||||
// Fetch SSH key on first open
|
||||
show_ssh_key.set(true);
|
||||
spawn(async move {
|
||||
match crate::infrastructure::repositories::fetch_ssh_public_key().await {
|
||||
Ok(key) => ssh_public_key.set(key),
|
||||
Err(_) => ssh_public_key.set("(not available)".to_string()),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
if show_auth() { "Hide auth options" } else { "Private repository?" }
|
||||
}
|
||||
}
|
||||
|
||||
if show_auth() {
|
||||
div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;",
|
||||
// SSH deploy key display
|
||||
div { style: "margin-bottom: 12px;",
|
||||
label { style: "font-size: 12px; color: var(--text-secondary);",
|
||||
"For SSH URLs: add this deploy key (read-only) to your repository"
|
||||
}
|
||||
div {
|
||||
style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;",
|
||||
if ssh_public_key().is_empty() {
|
||||
"Loading..."
|
||||
} else {
|
||||
"{ssh_public_key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS auth fields
|
||||
p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||
"For HTTPS URLs: provide an access token (PAT) or username/password"
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Auth Token / Password" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "ghp_xxxx or personal access token",
|
||||
value: "{auth_token}",
|
||||
oninput: move |e| auth_token.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Username (optional, defaults to x-access-token)" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "x-access-token",
|
||||
value: "{auth_username}",
|
||||
oninput: move |e| auth_username.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: adding(),
|
||||
onclick: move |_| {
|
||||
let n = name();
|
||||
let u = git_url();
|
||||
let b = branch();
|
||||
let tok = {
|
||||
let v = auth_token();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
};
|
||||
let usr = {
|
||||
let v = auth_username();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
};
|
||||
adding.set(true);
|
||||
spawn(async move {
|
||||
match crate::infrastructure::repositories::add_repository(n, u, b).await {
|
||||
match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Repository added");
|
||||
repos.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
adding.set(false);
|
||||
});
|
||||
show_add_form.set(false);
|
||||
show_auth.set(false);
|
||||
name.set(String::new());
|
||||
git_url.set(String::new());
|
||||
auth_token.set(String::new());
|
||||
auth_username.set(String::new());
|
||||
},
|
||||
"Add"
|
||||
if adding() { "Validating..." } else { "Add" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +255,7 @@ pub fn RepositoriesPage() -> Element {
|
||||
let repo_id_scan = repo_id.clone();
|
||||
let repo_id_del = repo_id.clone();
|
||||
let repo_name_del = repo.name.clone();
|
||||
let is_scanning = scanning_ids().contains(&repo_id);
|
||||
rsx! {
|
||||
tr {
|
||||
td { "{repo.name}" }
|
||||
@@ -192,17 +290,44 @@ pub fn RepositoriesPage() -> Element {
|
||||
"Graph"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||
disabled: is_scanning,
|
||||
onclick: move |_| {
|
||||
let id = repo_id_scan.clone();
|
||||
// Add to scanning set
|
||||
let mut ids = scanning_ids();
|
||||
ids.push(id.clone());
|
||||
scanning_ids.set(ids);
|
||||
spawn(async move {
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Scan triggered");
|
||||
// Poll until scan completes
|
||||
loop {
|
||||
async_sleep_5s().await;
|
||||
match crate::infrastructure::repositories::check_repo_scanning(id.clone()).await {
|
||||
Ok(false) => break,
|
||||
Ok(true) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
toasts.push(ToastType::Success, "Scan complete");
|
||||
repos.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
// Remove from scanning set
|
||||
let mut ids = scanning_ids();
|
||||
ids.retain(|i| i != &id);
|
||||
scanning_ids.set(ids);
|
||||
});
|
||||
},
|
||||
"Scan"
|
||||
if is_scanning {
|
||||
span { class: "spinner" }
|
||||
"Scanning..."
|
||||
} else {
|
||||
"Scan"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-ghost-danger",
|
||||
|
||||
Reference in New Issue
Block a user