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

This commit was merged in pull request #6.
This commit is contained in:
2026-03-09 12:53:12 +00:00
parent 32e5fc21e7
commit 46bf9de549
40 changed files with 2048 additions and 118 deletions

View File

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

View File

@@ -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()),
})
}

View File

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

View File

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

View File

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

View File

@@ -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! {

View File

@@ -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 {

View File

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