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

This commit was merged in pull request #161.
This commit is contained in:
2026-07-16 07:25:45 +00:00
parent 87f240b26f
commit a981311413
46 changed files with 386 additions and 2123 deletions
+28 -6
View File
@@ -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 },
+29 -25
View File
@@ -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}"
}
}
}
}
-2
View File
@@ -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;
+4 -4
View File
@@ -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..." }
},
}
}
}