refactor: rip out the legacy TrackedRepository / repositories path (#161)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 8m20s
CI / Deploy Dashboard (push) Successful in 3m13s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m7s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 8m20s
CI / Deploy Dashboard (push) Successful in 3m13s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m7s
This commit was merged in pull request #161.
This commit is contained in:
@@ -10,8 +10,6 @@ pub enum Route {
|
||||
#[layout(AppShell)]
|
||||
#[route("/")]
|
||||
OverviewPage {},
|
||||
#[route("/repositories")]
|
||||
RepositoriesPage {},
|
||||
#[route("/targets")]
|
||||
TargetsPage {},
|
||||
#[route("/onboard")]
|
||||
|
||||
@@ -4,8 +4,9 @@ use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::infrastructure::dast::fetch_dast_targets;
|
||||
use crate::infrastructure::onboarding::fetch_targets;
|
||||
use crate::infrastructure::pentest::{create_pentest_session_wizard, lookup_repo_by_url};
|
||||
use crate::infrastructure::repositories::{fetch_repositories, fetch_ssh_public_key};
|
||||
use crate::infrastructure::repositories::fetch_ssh_public_key;
|
||||
|
||||
const DISCLAIMER_TEXT: &str = "I confirm that I have authorization to perform security testing \
|
||||
against the specified target. I understand that penetration testing may cause disruption to the \
|
||||
@@ -39,7 +40,7 @@ pub fn PentestWizard(show: Signal<bool>) -> Element {
|
||||
let mut show_target_dropdown = use_signal(|| false);
|
||||
let mut show_repo_dropdown = use_signal(|| false);
|
||||
let existing_targets = use_resource(|| async { fetch_dast_targets().await.ok() });
|
||||
let existing_repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
let existing_repos = use_resource(|| async { fetch_targets().await.ok() });
|
||||
|
||||
// SSH key state for private repos
|
||||
let mut ssh_public_key = use_signal(String::new);
|
||||
@@ -211,7 +212,25 @@ pub fn PentestWizard(show: Signal<bool>) -> Element {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.iter()
|
||||
.map(|r| (r.git_url.clone(), r.name.clone()))
|
||||
.filter_map(|t| {
|
||||
let name = t
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let git_url = t
|
||||
.get("artifacts")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|a| {
|
||||
a.get("kind").and_then(|k| k.as_str()) == Some("git_repo")
|
||||
})
|
||||
})
|
||||
.and_then(|a| a.get("source_ref"))
|
||||
.and_then(|s| s.as_str())?
|
||||
.to_string();
|
||||
Some((git_url, name))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ impl Database {
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
pub fn repositories(&self) -> Collection<TrackedRepository> {
|
||||
self.inner.collection("repositories")
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
self.inner.collection("findings")
|
||||
}
|
||||
|
||||
@@ -1,145 +1,10 @@
|
||||
//! The agent's SSH deploy public key — shown so a read-only deploy key can be
|
||||
//! added to private git targets. (The legacy repositories CRUD moved to the
|
||||
//! unified onboarding/targets API.)
|
||||
|
||||
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 path = format!("/api/v1/repositories?page={page}&limit=20");
|
||||
let resp = super::agent_client::agent_get(&path)
|
||||
.await?
|
||||
.send()
|
||||
.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,
|
||||
auth_token: Option<String>,
|
||||
auth_username: Option<String>,
|
||||
tracker_type: Option<String>,
|
||||
tracker_owner: Option<String>,
|
||||
tracker_repo: Option<String>,
|
||||
tracker_token: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
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);
|
||||
}
|
||||
if let Some(tt) = tracker_type.filter(|t| !t.is_empty()) {
|
||||
body["tracker_type"] = serde_json::Value::String(tt);
|
||||
}
|
||||
if let Some(to) = tracker_owner.filter(|t| !t.is_empty()) {
|
||||
body["tracker_owner"] = serde_json::Value::String(to);
|
||||
}
|
||||
if let Some(tr) = tracker_repo.filter(|t| !t.is_empty()) {
|
||||
body["tracker_repo"] = serde_json::Value::String(tr);
|
||||
}
|
||||
if let Some(tk) = tracker_token.filter(|t| !t.is_empty()) {
|
||||
body["tracker_token"] = serde_json::Value::String(tk);
|
||||
}
|
||||
|
||||
let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/repositories")
|
||||
.await?
|
||||
.json(&body)
|
||||
.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 update_repository(
|
||||
repo_id: String,
|
||||
name: Option<String>,
|
||||
default_branch: Option<String>,
|
||||
auth_token: Option<String>,
|
||||
auth_username: Option<String>,
|
||||
tracker_type: Option<String>,
|
||||
tracker_owner: Option<String>,
|
||||
tracker_repo: Option<String>,
|
||||
tracker_token: Option<String>,
|
||||
scan_schedule: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(v) = name.filter(|s| !s.is_empty()) {
|
||||
body.insert("name".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = default_branch.filter(|s| !s.is_empty()) {
|
||||
body.insert("default_branch".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = auth_token {
|
||||
body.insert("auth_token".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = auth_username {
|
||||
body.insert("auth_username".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = tracker_type {
|
||||
body.insert("tracker_type".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = tracker_owner {
|
||||
body.insert("tracker_owner".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = tracker_repo {
|
||||
body.insert("tracker_repo".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = tracker_token {
|
||||
body.insert("tracker_token".into(), serde_json::Value::String(v));
|
||||
}
|
||||
if let Some(v) = scan_schedule {
|
||||
body.insert("scan_schedule".into(), serde_json::Value::String(v));
|
||||
}
|
||||
|
||||
let resp = super::agent_client::agent_request(
|
||||
reqwest::Method::PATCH,
|
||||
&format!("/api/v1/repositories/{repo_id}"),
|
||||
)
|
||||
.await?
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Failed to update repository: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the agent's SSH deploy public key.
|
||||
#[server]
|
||||
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||
let resp = super::agent_client::agent_get("/api/v1/settings/ssh-public-key")
|
||||
@@ -163,86 +28,3 @@ pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||
let resp = super::agent_client::agent_request(
|
||||
reqwest::Method::DELETE,
|
||||
&format!("/api/v1/repositories/{repo_id}"),
|
||||
)
|
||||
.await?
|
||||
.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 delete repository: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
super::agent_client::agent_request(
|
||||
reqwest::Method::POST,
|
||||
&format!("/api/v1/repositories/{repo_id}/scan"),
|
||||
)
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WebhookConfigResponse {
|
||||
pub webhook_secret: Option<String>,
|
||||
pub tracker_type: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_webhook_config(repo_id: String) -> Result<WebhookConfigResponse, ServerFnError> {
|
||||
let resp =
|
||||
super::agent_client::agent_get(&format!("/api/v1/repositories/{repo_id}/webhook-config"))
|
||||
.await?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: WebhookConfigResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Check if a repository has any running scans
|
||||
#[server]
|
||||
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
|
||||
let resp = super::agent_client::agent_get("/api/v1/scan-runs?page=1&limit=1")
|
||||
.await?
|
||||
.send()
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
use crate::infrastructure::onboarding::fetch_targets;
|
||||
|
||||
#[component]
|
||||
pub fn ChatIndexPage() -> Element {
|
||||
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
let repos = use_resource(|| async { fetch_targets().await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
@@ -28,10 +28,32 @@ pub fn ChatIndexPage() -> Element {
|
||||
div { class: "graph-index-grid",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let url = repo.git_url.clone();
|
||||
let branch = repo.default_branch.clone();
|
||||
let repo_id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
|
||||
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
|
||||
let url = repo
|
||||
.get("artifacts")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|a| {
|
||||
a.get("kind").and_then(|k| k.as_str()) == Some("git_repo")
|
||||
})
|
||||
})
|
||||
.and_then(|a| a.get("source_ref"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let branch = repo
|
||||
.get("artifacts")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|a| {
|
||||
a.get("git")
|
||||
.and_then(|g| g.get("default_branch"))
|
||||
.and_then(|b| b.as_str())
|
||||
})
|
||||
})
|
||||
.unwrap_or("main")
|
||||
.to_string();
|
||||
rsx! {
|
||||
Link {
|
||||
to: Route::ChatPage { repo_id },
|
||||
|
||||
@@ -2,11 +2,11 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
use crate::infrastructure::onboarding::fetch_targets;
|
||||
|
||||
#[component]
|
||||
pub fn GraphIndexPage() -> Element {
|
||||
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
let repos = use_resource(|| async { fetch_targets().await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
@@ -28,27 +28,34 @@ pub fn GraphIndexPage() -> Element {
|
||||
div { class: "graph-index-grid",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let url = repo.git_url.clone();
|
||||
let branch = repo.default_branch.clone();
|
||||
let findings = repo.findings_count;
|
||||
let repo_id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
|
||||
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
|
||||
let url = repo
|
||||
.get("artifacts")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|a| {
|
||||
a.get("kind").and_then(|k| k.as_str()) == Some("git_repo")
|
||||
})
|
||||
})
|
||||
.and_then(|a| a.get("source_ref"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let branch = repo
|
||||
.get("artifacts")
|
||||
.and_then(|a| a.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|a| {
|
||||
a.get("git")
|
||||
.and_then(|g| g.get("default_branch"))
|
||||
.and_then(|b| b.as_str())
|
||||
})
|
||||
})
|
||||
.unwrap_or("main")
|
||||
.to_string();
|
||||
let findings = repo.get("findings_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let findings_label = if findings != 1 { format!("{findings} findings") } else { "1 finding".to_string() };
|
||||
let updated = {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(repo.updated_at);
|
||||
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! {
|
||||
Link {
|
||||
to: Route::GraphExplorerPage { repo_id },
|
||||
@@ -67,9 +74,6 @@ pub fn GraphIndexPage() -> Element {
|
||||
span { class: "graph-repo-card-tag graph-repo-card-tag-findings",
|
||||
"{findings_label}"
|
||||
}
|
||||
span { class: "graph-repo-card-tag",
|
||||
"Updated {updated}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ pub mod onboarding;
|
||||
pub mod overview;
|
||||
pub mod pentest_dashboard;
|
||||
pub mod pentest_session;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod targets;
|
||||
|
||||
@@ -38,6 +37,5 @@ pub use onboarding::OnboardingPage;
|
||||
pub use overview::OverviewPage;
|
||||
pub use pentest_dashboard::PentestDashboardPage;
|
||||
pub use pentest_session::PentestSessionPage;
|
||||
pub use repositories::RepositoriesPage;
|
||||
pub use sbom::SbomPage;
|
||||
pub use targets::TargetsPage;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::stat_card::StatCard;
|
||||
use crate::infrastructure::mcp::fetch_mcp_servers;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
use crate::infrastructure::onboarding::fetch_targets;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::infrastructure::stats::fetch_overview_stats;
|
||||
@@ -26,7 +26,7 @@ pub fn OverviewPage() -> Element {
|
||||
}
|
||||
});
|
||||
|
||||
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
let repos = use_resource(|| async { fetch_targets().await.ok() });
|
||||
let mcp_servers = use_resource(|| async { fetch_mcp_servers().await.ok() });
|
||||
|
||||
rsx! {
|
||||
@@ -94,8 +94,8 @@ pub fn OverviewPage() -> Element {
|
||||
style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1rem;",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let repo_id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
|
||||
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
|
||||
rsx! {
|
||||
Link {
|
||||
to: Route::ChatPage { repo_id },
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
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 toasts = use_context::<Toasts>();
|
||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||
let mut edit_repo_id = use_signal(|| Option::<String>::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::<String>::None);
|
||||
let mut edit_webhook_tracker = use_signal(String::new);
|
||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||
let mut graph_repo_id = use_signal(|| Option::<String>::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: "Legacy git repositories. Onboard new targets from Targets / Onboard.",
|
||||
}
|
||||
|
||||
// ── 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" }
|
||||
{
|
||||
#[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();
|
||||
let webhook_url = format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker());
|
||||
rsx! {
|
||||
div { class: "copyable",
|
||||
input {
|
||||
r#type: "text",
|
||||
readonly: true,
|
||||
style: "font-family: monospace; font-size: 12px; flex: 1;",
|
||||
value: "{webhook_url}",
|
||||
}
|
||||
crate::components::copy_button::CopyButton { value: webhook_url.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Webhook Secret" }
|
||||
div { class: "copyable",
|
||||
input {
|
||||
r#type: "text",
|
||||
readonly: true,
|
||||
style: "font-family: monospace; font-size: 12px; flex: 1;",
|
||||
value: "{secret}",
|
||||
}
|
||||
crate::components::copy_button::CopyButton { value: secret.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
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..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user