use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; #[allow(unused_imports)] use dioxus_free_icons::icons::bs_icons::{BsGear, BsPencil}; use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; use crate::components::toast::{ToastType, Toasts}; use crate::pages::graph_explorer::GraphExplorerInline; 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); 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 mut auth_token = use_signal(String::new); let mut auth_username = use_signal(String::new); let mut show_auth = use_signal(|| false); let mut ssh_public_key = use_signal(String::new); let mut show_tracker = use_signal(|| false); let mut tracker_type_val = use_signal(String::new); let mut tracker_owner_val = use_signal(String::new); let mut tracker_repo_val = use_signal(String::new); let mut tracker_token_val = use_signal(String::new); let mut adding = use_signal(|| false); let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut edit_repo_id = use_signal(|| Option::::None); let mut edit_name = use_signal(String::new); let mut edit_branch = use_signal(String::new); let mut edit_tracker_type = use_signal(String::new); let mut edit_tracker_owner = use_signal(String::new); let mut edit_tracker_repo = use_signal(String::new); let mut edit_tracker_token = use_signal(String::new); let mut edit_saving = use_signal(|| false); let mut edit_webhook_secret = use_signal(|| Option::::None); let mut edit_webhook_tracker = use_signal(String::new); let mut scanning_ids = use_signal(Vec::::new); let mut graph_repo_id = use_signal(|| Option::::None); let mut 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 or git@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()), } } // Private repo auth section div { style: "margin-top: 8px;", button { class: "btn btn-ghost", style: "font-size: 12px; padding: 4px 8px;", onclick: move |_| { let opening = !show_auth(); show_auth.toggle(); if opening { // Fetch SSH key every time the section opens ssh_public_key.set(String::new()); 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()), } } } } // Issue tracker config section div { style: "margin-top: 8px;", button { class: "btn btn-ghost", style: "font-size: 12px; padding: 4px 8px;", onclick: move |_| show_tracker.toggle(), if show_tracker() { "Hide tracker options" } else { "Issue tracker?" } } } if show_tracker() { div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", "Configure an issue tracker to auto-create issues from findings" } div { class: "form-group", label { "Tracker Type" } select { value: "{tracker_type_val}", onchange: move |e| tracker_type_val.set(e.value()), option { value: "", "None" } option { value: "github", "GitHub" } option { value: "gitlab", "GitLab" } option { value: "gitea", "Gitea" } option { value: "jira", "Jira" } } } div { class: "form-group", label { "Owner / Namespace" } input { r#type: "text", placeholder: "org-name", value: "{tracker_owner_val}", oninput: move |e| tracker_owner_val.set(e.value()), } } div { class: "form-group", label { "Repository / Project" } input { r#type: "text", placeholder: "repo-name", value: "{tracker_repo_val}", oninput: move |e| tracker_repo_val.set(e.value()), } } div { class: "form-group", label { "Tracker Token (PAT)" } input { r#type: "password", placeholder: "ghp_xxxx / glpat-xxxx", value: "{tracker_token_val}", oninput: move |e| tracker_token_val.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) } }; let tt = { let v = tracker_type_val(); if v.is_empty() { None } else { Some(v) } }; let t_owner = { let v = tracker_owner_val(); if v.is_empty() { None } else { Some(v) } }; let t_repo = { let v = tracker_repo_val(); if v.is_empty() { None } else { Some(v) } }; let t_tok = { let v = tracker_token_val(); if v.is_empty() { None } else { Some(v) } }; adding.set(true); spawn(async move { match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr, tt, t_owner, t_repo, t_tok).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); show_tracker.set(false); name.set(String::new()); git_url.set(String::new()); auth_token.set(String::new()); auth_username.set(String::new()); tracker_type_val.set(String::new()); tracker_owner_val.set(String::new()); tracker_repo_val.set(String::new()); tracker_token_val.set(String::new()); }, if adding() { "Validating..." } else { "Add" } } } } // ── Delete confirmation dialog ── if let Some((del_id, del_name)) = confirm_delete() { div { class: "modal-overlay", div { class: "modal-dialog", h3 { "Delete Repository" } p { "Are you sure you want to delete " strong { "{del_name}" } "?" } p { class: "modal-warning", "This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts." } div { class: "modal-actions", button { class: "btn btn-secondary", onclick: move |_| confirm_delete.set(None), "Cancel" } button { class: "btn btn-danger", onclick: move |_| { let id = del_id.clone(); let name = del_name.clone(); confirm_delete.set(None); spawn(async move { match crate::infrastructure::repositories::delete_repository(id).await { Ok(_) => { toasts.push(ToastType::Success, format!("{name} deleted")); repos.restart(); } Err(e) => toasts.push(ToastType::Error, e.to_string()), } }); }, "Delete" } } } } } // ── Edit repository dialog ── if let Some(eid) = edit_repo_id() { div { class: "modal-overlay", div { class: "modal-dialog", h3 { "Edit Repository" } div { class: "form-group", label { "Name" } input { r#type: "text", value: "{edit_name}", oninput: move |e| edit_name.set(e.value()), } } div { class: "form-group", label { "Default Branch" } input { r#type: "text", value: "{edit_branch}", oninput: move |e| edit_branch.set(e.value()), } } h4 { style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", "Issue Tracker" } div { class: "form-group", label { "Tracker Type" } select { value: "{edit_tracker_type}", onchange: move |e| edit_tracker_type.set(e.value()), option { value: "", "None" } option { value: "github", "GitHub" } option { value: "gitlab", "GitLab" } option { value: "gitea", "Gitea" } option { value: "jira", "Jira" } } } div { class: "form-group", label { "Owner / Namespace" } input { r#type: "text", placeholder: "org-name", value: "{edit_tracker_owner}", oninput: move |e| edit_tracker_owner.set(e.value()), } } div { class: "form-group", label { "Repository / Project" } input { r#type: "text", placeholder: "repo-name", value: "{edit_tracker_repo}", oninput: move |e| edit_tracker_repo.set(e.value()), } } div { class: "form-group", label { "Tracker Token (leave empty to keep existing)" } input { r#type: "password", placeholder: "Enter new token to change", value: "{edit_tracker_token}", oninput: move |e| edit_tracker_token.set(e.value()), } } // Webhook configuration section if let Some(secret) = edit_webhook_secret() { h4 { style: "margin-top: 16px; margin-bottom: 8px; font-size: 14px; color: var(--text-secondary);", "Webhook Configuration" } p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", "Add this webhook in your repository settings to enable push-triggered scans and PR reviews." } div { class: "form-group", label { "Webhook URL" } input { r#type: "text", readonly: true, style: "font-family: monospace; font-size: 12px;", value: { #[cfg(feature = "web")] let origin = web_sys::window() .and_then(|w: web_sys::Window| w.location().origin().ok()) .unwrap_or_default(); #[cfg(not(feature = "web"))] let origin = String::new(); format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker()) }, } } div { class: "form-group", label { "Webhook Secret" } input { r#type: "text", readonly: true, style: "font-family: monospace; font-size: 12px;", value: "{secret}", } } } div { class: "modal-actions", button { class: "btn btn-secondary", onclick: move |_| edit_repo_id.set(None), "Cancel" } button { class: "btn btn-primary", disabled: edit_saving(), onclick: move |_| { let id = eid.clone(); let nm = { let v = edit_name(); if v.is_empty() { None } else { Some(v) } }; let br = { let v = edit_branch(); if v.is_empty() { None } else { Some(v) } }; let tt = { let v = edit_tracker_type(); if v.is_empty() { None } else { Some(v) } }; let t_owner = { let v = edit_tracker_owner(); if v.is_empty() { None } else { Some(v) } }; let t_repo = { let v = edit_tracker_repo(); if v.is_empty() { None } else { Some(v) } }; let t_tok = { let v = edit_tracker_token(); if v.is_empty() { None } else { Some(v) } }; edit_saving.set(true); spawn(async move { match crate::infrastructure::repositories::update_repository( id, nm, br, None, None, tt, t_owner, t_repo, t_tok, None, ).await { Ok(_) => { toasts.push(ToastType::Success, "Repository updated"); repos.restart(); } Err(e) => toasts.push(ToastType::Error, e.to_string()), } edit_saving.set(false); edit_repo_id.set(None); }); }, if edit_saving() { "Saving..." } else { "Save" } } } } } } 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_scan = repo_id.clone(); let repo_id_del = repo_id.clone(); let repo_id_edit = repo_id.clone(); let repo_name_del = repo.name.clone(); let edit_repo_data = repo.clone(); let is_scanning = scanning_ids().contains(&repo_id); 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 { { let now = chrono::Utc::now(); let diff = now.signed_duration_since(repo.updated_at); let label = if diff.num_minutes() < 1 { "just now".to_string() } else if diff.num_hours() < 1 { format!("{}m ago", diff.num_minutes()) } else if diff.num_days() < 1 { format!("{}h ago", diff.num_hours()) } else if diff.num_days() < 30 { format!("{}d ago", diff.num_days()) } else { repo.updated_at.format("%Y-%m-%d").to_string() }; rsx! { span { style: "font-size: 12px;", "{label}" } } } } td { style: "display: flex; gap: 4px;", button { class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" }, title: "View graph", onclick: { let rid = repo_id.clone(); move |_| { if graph_repo_id().as_deref() == Some(rid.as_str()) { graph_repo_id.set(None); } else { graph_repo_id.set(Some(rid.clone())); } } }, Icon { icon: BsDiagram3, width: 16, height: 16 } } button { class: "btn btn-ghost", title: "Edit repository", onclick: move |_| { edit_name.set(edit_repo_data.name.clone()); edit_branch.set(edit_repo_data.default_branch.clone()); edit_tracker_type.set( edit_repo_data.tracker_type.as_ref().map(|t| t.to_string()).unwrap_or_default() ); edit_tracker_owner.set(edit_repo_data.tracker_owner.clone().unwrap_or_default()); edit_tracker_repo.set(edit_repo_data.tracker_repo.clone().unwrap_or_default()); edit_tracker_token.set(String::new()); edit_webhook_secret.set(None); edit_webhook_tracker.set(String::new()); edit_repo_id.set(Some(repo_id_edit.clone())); // Fetch webhook config in background let rid = repo_id_edit.clone(); spawn(async move { if let Ok(cfg) = crate::infrastructure::repositories::fetch_webhook_config(rid).await { edit_webhook_secret.set(cfg.webhook_secret); edit_webhook_tracker.set(cfg.tracker_type); } }); }, Icon { icon: BsPencil, width: 16, height: 16 } } button { class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, title: "Trigger scan", 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.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); }); }, if is_scanning { span { class: "spinner" } } else { Icon { icon: BsPlayCircle, width: 16, height: 16 } } } button { class: "btn btn-ghost btn-ghost-danger", title: "Delete repository", onclick: move |_| { confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone()))); }, Icon { icon: BsTrash, width: 16, height: 16 } } } } } } } } } } Pagination { current_page: page(), total_pages: total_pages, on_page_change: move |p| page.set(p), } } // Inline graph explorer if let Some(rid) = graph_repo_id() { div { class: "card", style: "margin-top: 16px;", div { class: "card-header", style: "display: flex; justify-content: space-between; align-items: center;", span { "Code Graph" } button { class: "btn btn-sm btn-ghost", title: "Close graph", onclick: move |_| { graph_repo_id.set(None); }, Icon { icon: BsX, width: 18, height: 18 } } } GraphExplorerInline { repo_id: rid } } } } }, Some(None) => rsx! { div { class: "card", p { "Failed to load repositories." } } }, None => rsx! { div { class: "loading", "Loading repositories..." } }, } } }