From a5902c0a5e643b0c553f50845de41e9fe73765c8 Mon Sep 17 00:00:00 2001 From: yuyr Date: Sat, 18 Jul 2026 10:38:38 +0800 Subject: [PATCH] =?UTF-8?q?20260718=20=E9=87=8D=E6=9E=84=20RPKI=20Explorer?= =?UTF-8?q?=20=E6=9F=A5=E8=AF=A2=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bin/rpki_query_service.rs | 262 ++- src/query/report_stream.rs | 97 +- src/query_db.rs | 137 ++ ui/rpki-explorer/README.md | 120 +- ui/rpki-explorer/package-lock.json | 517 +---- ui/rpki-explorer/package.json | 8 +- ui/rpki-explorer/playwright.config.ts | 16 +- ui/rpki-explorer/src/api/client.test.ts | 102 + ui/rpki-explorer/src/api/client.ts | 164 +- ui/rpki-explorer/src/api/queryService.ts | 301 --- ui/rpki-explorer/src/api/schemas.ts | 308 +++ ui/rpki-explorer/src/api/service.ts | 450 +++++ ui/rpki-explorer/src/app/App.tsx | 137 +- ui/rpki-explorer/src/app/providers.tsx | 7 +- ui/rpki-explorer/src/app/queryClient.ts | 19 +- .../src/components/CopyableValue.tsx | 61 + .../src/components/CursorPagerControls.tsx | 48 + ui/rpki-explorer/src/components/DataTable.tsx | 104 + ui/rpki-explorer/src/components/KpiCard.tsx | 33 + .../src/components/ObjectsTable.tsx | 218 +++ .../src/components/PageHeader.tsx | 21 + ui/rpki-explorer/src/components/Panel.tsx | 34 + .../src/components/ProjectionView.tsx | 305 +++ ui/rpki-explorer/src/components/Shell.tsx | 176 ++ .../src/components/StateBlock.tsx | 73 + .../src/components/StatusPill.tsx | 54 + ui/rpki-explorer/src/components/Tabs.tsx | 79 + .../src/components/WorkflowStatus.tsx | 44 + .../src/components/common/CopyableValue.tsx | 88 - .../src/components/common/CursorPager.tsx | 32 - .../src/components/common/cursorPaging.ts | 24 - .../src/components/layout/Shell.tsx | 78 - .../object-detail/ObjectDetailPage.tsx | 509 ----- .../src/features/overview/OverviewPage.tsx | 360 ---- .../repositories/RepositoriesPage.tsx | 467 ----- .../repositories/repositoryBrowserState.ts | 33 - ui/rpki-explorer/src/lib/cursor.test.ts | 42 + ui/rpki-explorer/src/lib/cursor.ts | 72 + ui/rpki-explorer/src/lib/format.test.ts | 99 + ui/rpki-explorer/src/lib/format.ts | 109 ++ ui/rpki-explorer/src/lib/projection.test.ts | 25 + ui/rpki-explorer/src/lib/projection.ts | 18 + ui/rpki-explorer/src/lib/run.ts | 48 + ui/rpki-explorer/src/lib/useRun.ts | 15 + ui/rpki-explorer/src/main.tsx | 16 +- ui/rpki-explorer/src/pages/ApiStatusPage.tsx | 111 ++ ui/rpki-explorer/src/pages/ExportsPage.tsx | 100 + ui/rpki-explorer/src/pages/NotFoundPage.tsx | 17 + .../src/pages/ObjectDetailPage.tsx | 393 ++++ ui/rpki-explorer/src/pages/ObjectsPage.tsx | 71 + ui/rpki-explorer/src/pages/OverviewPage.tsx | 381 ++++ .../src/pages/PublicationPointDetailPage.tsx | 115 ++ .../src/pages/PublicationPointsPage.tsx | 125 ++ .../src/pages/RepositoriesPage.tsx | 144 ++ .../src/pages/RepositoryDetailPage.tsx | 228 +++ ui/rpki-explorer/src/pages/RunsPage.tsx | 145 ++ ui/rpki-explorer/src/pages/SearchPage.tsx | 171 ++ ui/rpki-explorer/src/pages/ValidationPage.tsx | 272 +++ ui/rpki-explorer/src/styles/base.css | 108 ++ ui/rpki-explorer/src/styles/components.css | 603 ++++++ ui/rpki-explorer/src/styles/globals.css | 1707 ----------------- ui/rpki-explorer/src/styles/pages.css | 217 +++ ui/rpki-explorer/src/styles/shell.css | 316 +++ ui/rpki-explorer/src/styles/tokens.css | 67 + .../src/test/fixtures/dashboard.ts | 112 -- .../tests/e2e/copyable-values.spec.ts | 62 - ui/rpki-explorer/tests/e2e/fixtures.ts | 321 ++++ ui/rpki-explorer/tests/e2e/mockApi.ts | 340 ++++ .../tests/e2e/object-detail-api.spec.ts | 65 - .../tests/e2e/object-detail.spec.ts | 88 + ui/rpki-explorer/tests/e2e/objects.spec.ts | 55 + .../tests/e2e/overview-api.spec.ts | 37 - ui/rpki-explorer/tests/e2e/overview.spec.ts | 63 + .../tests/e2e/repositories-api.spec.ts | 95 - .../tests/e2e/repositories.spec.ts | 78 + ui/rpki-explorer/tests/e2e/shell.spec.ts | 158 +- .../e2e/validation-search-exports.spec.ts | 70 + .../tests/e2e/workflows-api.spec.ts | 52 - ui/rpki-explorer/vitest.config.ts | 8 + 79 files changed, 7964 insertions(+), 4861 deletions(-) create mode 100644 ui/rpki-explorer/src/api/client.test.ts delete mode 100644 ui/rpki-explorer/src/api/queryService.ts create mode 100644 ui/rpki-explorer/src/api/schemas.ts create mode 100644 ui/rpki-explorer/src/api/service.ts create mode 100644 ui/rpki-explorer/src/components/CopyableValue.tsx create mode 100644 ui/rpki-explorer/src/components/CursorPagerControls.tsx create mode 100644 ui/rpki-explorer/src/components/DataTable.tsx create mode 100644 ui/rpki-explorer/src/components/KpiCard.tsx create mode 100644 ui/rpki-explorer/src/components/ObjectsTable.tsx create mode 100644 ui/rpki-explorer/src/components/PageHeader.tsx create mode 100644 ui/rpki-explorer/src/components/Panel.tsx create mode 100644 ui/rpki-explorer/src/components/ProjectionView.tsx create mode 100644 ui/rpki-explorer/src/components/Shell.tsx create mode 100644 ui/rpki-explorer/src/components/StateBlock.tsx create mode 100644 ui/rpki-explorer/src/components/StatusPill.tsx create mode 100644 ui/rpki-explorer/src/components/Tabs.tsx create mode 100644 ui/rpki-explorer/src/components/WorkflowStatus.tsx delete mode 100644 ui/rpki-explorer/src/components/common/CopyableValue.tsx delete mode 100644 ui/rpki-explorer/src/components/common/CursorPager.tsx delete mode 100644 ui/rpki-explorer/src/components/common/cursorPaging.ts delete mode 100644 ui/rpki-explorer/src/components/layout/Shell.tsx delete mode 100644 ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx delete mode 100644 ui/rpki-explorer/src/features/overview/OverviewPage.tsx delete mode 100644 ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx delete mode 100644 ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts create mode 100644 ui/rpki-explorer/src/lib/cursor.test.ts create mode 100644 ui/rpki-explorer/src/lib/cursor.ts create mode 100644 ui/rpki-explorer/src/lib/format.test.ts create mode 100644 ui/rpki-explorer/src/lib/format.ts create mode 100644 ui/rpki-explorer/src/lib/projection.test.ts create mode 100644 ui/rpki-explorer/src/lib/projection.ts create mode 100644 ui/rpki-explorer/src/lib/run.ts create mode 100644 ui/rpki-explorer/src/lib/useRun.ts create mode 100644 ui/rpki-explorer/src/pages/ApiStatusPage.tsx create mode 100644 ui/rpki-explorer/src/pages/ExportsPage.tsx create mode 100644 ui/rpki-explorer/src/pages/NotFoundPage.tsx create mode 100644 ui/rpki-explorer/src/pages/ObjectDetailPage.tsx create mode 100644 ui/rpki-explorer/src/pages/ObjectsPage.tsx create mode 100644 ui/rpki-explorer/src/pages/OverviewPage.tsx create mode 100644 ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx create mode 100644 ui/rpki-explorer/src/pages/PublicationPointsPage.tsx create mode 100644 ui/rpki-explorer/src/pages/RepositoriesPage.tsx create mode 100644 ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx create mode 100644 ui/rpki-explorer/src/pages/RunsPage.tsx create mode 100644 ui/rpki-explorer/src/pages/SearchPage.tsx create mode 100644 ui/rpki-explorer/src/pages/ValidationPage.tsx create mode 100644 ui/rpki-explorer/src/styles/base.css create mode 100644 ui/rpki-explorer/src/styles/components.css delete mode 100644 ui/rpki-explorer/src/styles/globals.css create mode 100644 ui/rpki-explorer/src/styles/pages.css create mode 100644 ui/rpki-explorer/src/styles/shell.css create mode 100644 ui/rpki-explorer/src/styles/tokens.css delete mode 100644 ui/rpki-explorer/src/test/fixtures/dashboard.ts delete mode 100644 ui/rpki-explorer/tests/e2e/copyable-values.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/fixtures.ts create mode 100644 ui/rpki-explorer/tests/e2e/mockApi.ts delete mode 100644 ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/object-detail.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/objects.spec.ts delete mode 100644 ui/rpki-explorer/tests/e2e/overview-api.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/overview.spec.ts delete mode 100644 ui/rpki-explorer/tests/e2e/repositories-api.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/repositories.spec.ts create mode 100644 ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts delete mode 100644 ui/rpki-explorer/tests/e2e/workflows-api.spec.ts create mode 100644 ui/rpki-explorer/vitest.config.ts diff --git a/src/bin/rpki_query_service.rs b/src/bin/rpki_query_service.rs index 83df155..d19163c 100644 --- a/src/bin/rpki_query_service.rs +++ b/src/bin/rpki_query_service.rs @@ -7,9 +7,10 @@ use std::process::Command; use std::sync::{Arc, Mutex}; use rpki::blob_store::ExternalRepoBytesDb; +use rpki::query::report_stream::{ObjectFilter, ObjectScope}; use rpki::query_db::{ - ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError, - ValidationExplainRecord, + ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb, + QueryDbError, ValidationExplainRecord, }; use serde::Serialize; use serde_json::{Value, json}; @@ -544,6 +545,18 @@ fn route_request( return Ok(json!({"data":{"service":"rpki_query_service","version":1}})); } match segments.as_slice() { + ["healthz"] => { + let latest_ready_run = db.latest_ready_run()?; + data_response( + &json!({ + "status": "ok", + "service": "rpki_query_service", + "version": 1, + "latestReadyRun": latest_ready_run, + }), + None, + ) + } ["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None), ["latest_run"] => { let run_id = db @@ -630,8 +643,15 @@ fn route_request( } ["runs", raw_run_id, "repos", repo_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; + let filter = object_filter_from_query(&query)?; page_response( - db.list_objects_for_repo(&run_id, repo_id, limit(&query), cursor(&query))?, + db.list_objects_filtered( + &run_id, + ObjectScope::Repo(repo_id.to_string()), + &filter, + limit(&query), + cursor(&query), + )?, Some(run_id), ) } @@ -669,18 +689,63 @@ fn route_request( } ["runs", raw_run_id, "publication-points", pp_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; + let filter = object_filter_from_query(&query)?; page_response( - db.list_objects_for_pp(&run_id, pp_id, limit(&query), cursor(&query))?, + db.list_objects_filtered( + &run_id, + ObjectScope::PublicationPoint(pp_id.to_string()), + &filter, + limit(&query), + cursor(&query), + )?, Some(run_id), ) } ["runs", raw_run_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; + let filter = object_filter_from_query(&query)?; page_response( - db.list_objects(&run_id, limit(&query), cursor(&query))?, + db.list_objects_filtered( + &run_id, + ObjectScope::All, + &filter, + limit(&query), + cursor(&query), + )?, Some(run_id), ) } + ["runs", raw_run_id, "issues"] => { + let run_id = resolve_run(db, raw_run_id)?; + let mut filter = object_filter_from_query(&query)?; + filter.issues_only = true; + filter.repo_id = query.get("repoId").cloned(); + filter.pp_id = query.get("ppId").cloned(); + page_response( + db.list_objects_filtered( + &run_id, + ObjectScope::All, + &filter, + limit(&query), + cursor(&query), + )?, + Some(run_id), + ) + } + ["runs", raw_run_id, "search"] => { + let run_id = resolve_run(db, raw_run_id)?; + let q = query + .get("q") + .map(String::as_str) + .unwrap_or("") + .trim() + .to_string(); + if q.is_empty() { + return Err(ApiError::new(400, "q query parameter is required")); + } + let limit = search_limit(&query); + data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id)) + } ["runs", raw_run_id, "objects", "by-uri"] => { let run_id = resolve_run(db, raw_run_id)?; let uri = query @@ -792,6 +857,18 @@ fn route_request( offset_cursor(&query), ) } + ["runs", raw_run_id, "exports"] => { + let run_id = resolve_run(db, raw_run_id)?; + let limit = exports_limit(&query); + page_response( + rpki::query_db::QueryPage { + data: list_export_jobs(export_jobs, &run_id, limit)?, + next_cursor: None, + limit, + }, + Some(run_id), + ) + } ["runs", raw_run_id, "exports", job_id] => { let run_id = resolve_run(db, raw_run_id)?; let job = get_export_job(db, export_jobs, &run_id, job_id)? @@ -1150,6 +1227,24 @@ fn get_export_job( Ok(db.get_export_job(run_id, job_id)?) } +fn list_export_jobs( + export_jobs: &ExportJobStore, + run_id: &str, + limit: usize, +) -> Result, ApiError> { + let jobs = export_jobs + .lock() + .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?; + let mut out = jobs + .values() + .filter(|job| job.run_id == run_id) + .cloned() + .collect::>(); + out.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + out.truncate(limit); + Ok(out) +} + fn run_export_job( db: &QueryDb, repo_bytes: &ExternalRepoBytesDb, @@ -1575,6 +1670,103 @@ fn projection_for_explain( Ok(Some(projection)) } +fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result { + let objects = search_run_objects(db, run_id, q, limit)?; + let lower = q.to_lowercase(); + let repos = db.search_repos(run_id, &lower, limit)?; + let publication_points = db.search_publication_points(run_id, &lower, limit)?; + Ok(json!({ + "objects": objects, + "repos": repos, + "publicationPoints": publication_points, + })) +} + +fn search_run_objects( + db: &QueryDb, + run_id: &str, + q: &str, + limit: usize, +) -> Result, ApiError> { + if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") { + let Some(index) = db.get_object_by_uri(run_id, q)? else { + return Ok(Vec::new()); + }; + let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? { + Some(full) => full, + None => object_instance_from_uri_index(&index), + }; + return Ok(vec![object]); + } + let lower = q.to_lowercase(); + if is_sha256_hex(q) { + if let Some(projection) = db.get_object_projection(&lower)? { + if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? { + return Ok(vec![full]); + } + return Ok(vec![object_instance_from_projection(run_id, &projection)]); + } + if let Some(object) = db.get_object_by_sha256(run_id, &lower)? { + return Ok(vec![object]); + } + return Ok(Vec::new()); + } + let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit()); + if is_hex { + let mut objects = Vec::new(); + for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? { + let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? { + Some(full) => full, + None => object_instance_from_projection(run_id, &projection), + }; + objects.push(object); + } + return Ok(objects); + } + Ok(Vec::new()) +} + +fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord { + ObjectInstanceRecord { + schema_version: 1, + run_id: index.run_id.clone(), + object_instance_id: index.object_instance_id.clone(), + uri: index.uri.clone(), + uri_hash: String::new(), + sha256: index.sha256.clone(), + object_type: String::new(), + result: String::new(), + detail_summary: None, + repo_id: index.repo_id.clone(), + pp_id: index.pp_id.clone(), + source_section: "uri_index".to_string(), + rejected: false, + reject_reason: None, + } +} + +fn object_instance_from_projection( + run_id: &str, + projection: &rpki::object_projection::ObjectProjectionRecord, +) -> ObjectInstanceRecord { + ObjectInstanceRecord { + schema_version: 1, + run_id: run_id.to_string(), + object_instance_id: String::new(), + uri: String::new(), + uri_hash: String::new(), + sha256: projection.sha256.clone(), + object_type: projection.object_type.clone(), + result: projection.parse_status.clone(), + detail_summary: projection.error_summary.clone(), + repo_id: String::new(), + pp_id: String::new(), + source_section: "hash_index".to_string(), + rejected: false, + reject_reason: None, + } +} + fn global_projection_for_hash( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, @@ -1878,6 +2070,66 @@ fn limit(query: &BTreeMap) -> usize { .clamp(1, 1000) } +fn search_limit(query: &BTreeMap) -> usize { + query + .get("limit") + .and_then(|value| value.parse::().ok()) + .unwrap_or(10) + .clamp(1, 50) +} + +fn exports_limit(query: &BTreeMap) -> usize { + query + .get("limit") + .and_then(|value| value.parse::().ok()) + .unwrap_or(50) + .clamp(1, 200) +} + +fn object_filter_from_query(query: &BTreeMap) -> Result { + let mut filter = ObjectFilter::default(); + if let Some(value) = query.get("type") { + if value.is_empty() { + return Err(ApiError::new(400, "type filter must not be empty")); + } + // Accept projection-vocabulary aliases so both `mft` and `manifest` + // (etc.) match the URI-derived report stream types. + let lower = value.to_ascii_lowercase(); + let normalized = match lower.as_str() { + "mft" => "manifest", + "cer" => "certificate", + "asa" => "aspa", + _ => lower.as_str(), + }; + filter.object_type = Some(normalized.to_string()); + } + if let Some(value) = query.get("result") { + if !matches!(value.to_ascii_lowercase().as_str(), "ok" | "error" | "skipped") { + return Err(ApiError::new(400, format!("invalid result filter: {value}"))); + } + filter.result = Some(value.clone()); + } + if let Some(value) = query.get("rejected") { + filter.rejected = Some(match value.as_str() { + "true" => true, + "false" => false, + _ => { + return Err(ApiError::new( + 400, + format!("invalid rejected filter: {value}"), + )); + } + }); + } + if let Some(value) = query.get("reason") { + filter.reason = Some(value.to_lowercase()); + } + if let Some(value) = query.get("q") { + filter.query = Some(value.to_lowercase()); + } + Ok(filter) +} + fn cursor(query: &BTreeMap) -> Option<&str> { query.get("cursor").map(String::as_str) } diff --git a/src/query/report_stream.rs b/src/query/report_stream.rs index 47710c1..27df24d 100644 --- a/src/query/report_stream.rs +++ b/src/query/report_stream.rs @@ -50,6 +50,68 @@ pub enum ObjectLookup { Sha256(String), } +/// Optional predicates applied while streaming report objects. `reason` and +/// `query` must be pre-lowercased by the caller; they are matched as +/// case-insensitive substrings. The other fields are exact matches. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ObjectFilter { + pub object_type: Option, + pub result: Option, + pub rejected: Option, + pub reason: Option, + pub query: Option, + pub repo_id: Option, + pub pp_id: Option, + pub issues_only: bool, +} + +impl ObjectFilter { + pub fn matches(&self, object: &ObjectInstanceRecord) -> bool { + if self.issues_only && !(object.rejected || object.result == "error") { + return false; + } + if let Some(rejected) = self.rejected + && object.rejected != rejected + { + return false; + } + if let Some(result) = self.result.as_deref() + && !object.result.eq_ignore_ascii_case(result) + { + return false; + } + if let Some(object_type) = self.object_type.as_deref() + && !object.object_type.eq_ignore_ascii_case(object_type) + { + return false; + } + if let Some(repo_id) = self.repo_id.as_deref() + && object.repo_id != repo_id + { + return false; + } + if let Some(pp_id) = self.pp_id.as_deref() + && object.pp_id != pp_id + { + return false; + } + if let Some(reason) = self.reason.as_deref() { + let Some(reject_reason) = object.reject_reason.as_deref() else { + return false; + }; + if !reject_reason.to_lowercase().contains(reason) { + return false; + } + } + if let Some(query) = self.query.as_deref() + && !object.uri.to_lowercase().contains(query) + { + return false; + } + true + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ObjectResolutionMeta { @@ -81,13 +143,37 @@ pub fn list_report_objects( scope: ObjectScope, limit: usize, cursor: Option<&str>, +) -> QueryDbResult> { + list_report_objects_filtered( + report_path, + run_id, + scope, + &ObjectFilter::default(), + limit, + cursor, + ) +} + +pub fn list_report_objects_filtered( + report_path: &Path, + run_id: &str, + scope: ObjectScope, + filter: &ObjectFilter, + limit: usize, + cursor: Option<&str>, ) -> QueryDbResult> { let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?; let file = File::open(report_path)?; let reader = BufReader::new(file); let mut deserializer = serde_json::Deserializer::from_reader(reader); - let mut state = - ObjectScanState::new_list(run_id, scope, limit, start_pp_index, start_object_index); + let mut state = ObjectScanState::new_list( + run_id, + scope, + filter.clone(), + limit, + start_pp_index, + start_object_index, + ); ObjectScanSeed { state: &mut state } .deserialize(&mut deserializer) .map_err(QueryDbError::from)?; @@ -530,6 +616,7 @@ struct ObjectScanState<'a> { run_id: &'a str, scope: ObjectScope, mode: ObjectScanMode, + filter: ObjectFilter, limit: usize, start_pp_index: u64, start_object_index: u64, @@ -544,6 +631,7 @@ impl<'a> ObjectScanState<'a> { fn new_list( run_id: &'a str, scope: ObjectScope, + filter: ObjectFilter, limit: usize, start_pp_index: u64, start_object_index: u64, @@ -552,6 +640,7 @@ impl<'a> ObjectScanState<'a> { run_id, scope, mode: ObjectScanMode::List, + filter, limit, start_pp_index, start_object_index, @@ -568,6 +657,7 @@ impl<'a> ObjectScanState<'a> { run_id, scope, mode: ObjectScanMode::Lookup(lookup), + filter: ObjectFilter::default(), limit: 1, start_pp_index: 0, start_object_index: 0, @@ -682,7 +772,8 @@ fn process_raw_publication_point( let after_cursor = pp_index > state.start_pp_index || (pp_index == state.start_pp_index && object_index >= state.start_object_index); - if after_cursor && state.data.len() < state.limit { + if after_cursor && state.data.len() < state.limit && state.filter.matches(&record) + { state.data.push(record); if state.data.len() == state.limit { state.next_cursor = Some(object_cursor(pp_index, object_index + 1)); diff --git a/src/query_db.rs b/src/query_db.rs index c68e36f..a8a6bdb 100644 --- a/src/query_db.rs +++ b/src/query_db.rs @@ -473,6 +473,31 @@ impl QueryDb { ) } + pub fn list_objects_filtered( + &self, + run_id: &str, + scope: ObjectScope, + filter: &report_stream::ObjectFilter, + limit: usize, + cursor: Option<&str>, + ) -> QueryDbResult> { + let Some(report_path) = self.report_path_for_run(run_id)? else { + return Ok(QueryPage { + data: Vec::new(), + next_cursor: None, + limit: limit.clamp(1, 1000), + }); + }; + report_stream::list_report_objects_filtered( + &report_path, + run_id, + scope, + filter, + limit, + cursor, + ) + } + pub fn get_object_by_instance_id( &self, run_id: &str, @@ -628,6 +653,118 @@ impl QueryDb { self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes()) } + pub fn search_object_projections_by_hash_prefix( + &self, + sha256_prefix: &str, + limit: usize, + ) -> QueryDbResult> { + let cf = self.cf(CF_OBJECTS_BY_HASH)?; + let prefix = format!("objhash/{sha256_prefix}"); + let mut out = Vec::new(); + let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward); + for item in self.db.iterator_cf(cf, mode) { + let (key, value) = item?; + if !String::from_utf8_lossy(&key).starts_with(&prefix) { + break; + } + if out.len() >= limit { + break; + } + out.push(serde_json::from_slice(&value)?); + } + Ok(out) + } + + pub fn get_cached_object_by_sha256( + &self, + run_id: &str, + sha256: &str, + ) -> QueryDbResult> { + let mut cursor = None; + loop { + let page: QueryPage = self.list_json_by_prefix( + CF_OBJECT_INSTANCES, + &format!("objinst/{run_id}/"), + 1000, + cursor.as_deref(), + )?; + if let Some(found) = page + .data + .into_iter() + .find(|item| item.sha256.eq_ignore_ascii_case(sha256)) + { + return Ok(Some(found)); + } + let Some(next_cursor) = page.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } + } + + pub fn search_repos( + &self, + run_id: &str, + query_lower: &str, + limit: usize, + ) -> QueryDbResult> { + let cf = self.cf(CF_REPOS)?; + let prefix = format!("repo/{run_id}/"); + let mut out = Vec::new(); + let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward); + for item in self.db.iterator_cf(cf, mode) { + let (key, value) = item?; + if !String::from_utf8_lossy(&key).starts_with(&prefix) { + break; + } + if out.len() >= limit { + break; + } + let repo: RepositoryRecord = serde_json::from_slice(&value)?; + if repo.host.to_lowercase().contains(query_lower) + || repo.uri.to_lowercase().contains(query_lower) + { + out.push(repo); + } + } + Ok(out) + } + + pub fn search_publication_points( + &self, + run_id: &str, + query_lower: &str, + limit: usize, + ) -> QueryDbResult> { + let cf = self.cf(CF_PUBLICATION_POINTS)?; + let prefix = format!("pp/{run_id}/"); + let mut out = Vec::new(); + let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward); + for item in self.db.iterator_cf(cf, mode) { + let (key, value) = item?; + if !String::from_utf8_lossy(&key).starts_with(&prefix) { + break; + } + if out.len() >= limit { + break; + } + let pp: PublicationPointRecord = serde_json::from_slice(&value)?; + let uri_matches = [ + pp.manifest_rsync_uri.as_deref(), + pp.publication_point_rsync_uri.as_deref(), + pp.rrdp_notification_uri.as_deref(), + pp.rsync_base_uri.as_deref(), + ] + .into_iter() + .flatten() + .any(|uri| uri.to_lowercase().contains(query_lower)); + if uri_matches { + out.push(pp); + } + } + Ok(out) + } + pub fn get_stat( &self, run_id: &str, diff --git a/ui/rpki-explorer/README.md b/ui/rpki-explorer/README.md index cd90552..14c29ea 100644 --- a/ui/rpki-explorer/README.md +++ b/ui/rpki-explorer/README.md @@ -1,81 +1,111 @@ # RPKI Explorer -RPKI Explorer is the SPA frontend for browsing ours RP `rpki_query_service` outputs. +RPKI Explorer is the web UI for browsing the outputs of the ours RP +`rpki_query_service` — runs, repositories, publication points, objects, +validation issues and evidence exports. It is a read-only query layer: it does +not change validation logic or RP state. -## Scope +## Features -Current MVP covers: +- **Overview** — latest-run health at a glance: VRP/ASPA/object/PP counts, + validation result and object type charts, top repositories, top reject + reasons, all with drill-down links. +- **Repositories** — paginated repository list, repository detail with sync + phases/terminal states, publication points and scoped object browsing. +- **Publication Points** — global PP list and PP detail (URIs, sync state, + thisUpdate/nextUpdate, objects). +- **Objects** — global object browser with **server-side filters** + (`type`, `result`, `rejected`, `q` URI substring) and cursor paging. +- **Object detail** — structured parsed projections (ROA prefixes, ASPA + providers, manifest file list, CRL revocations, certificate fields), + validation summary, on-demand *explain* (audit projection, non-authoritative), + chain edges, raw DER download, object-set export. +- **Validation** — result distribution, reject-reason distribution with + click-to-filter, paginated issue list (`/issues`). +- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex + prefix), repository host/URI substring, PP URI substring. +- **Exports** — export job list with live polling and tarball download. +- **Runs** — indexed run history; pin any run as the browsing context via the + run selector in the top bar (`?run=` URL param, deep-linkable everywhere). +- **API** — service health and endpoint reference. -- Overview dashboard from latest run, stats, validation reasons, and top repositories. -- Repository -> publication point -> object lazy browser. -- Object detail with live object record, validation summary, lazy parsed/chain/list tabs, and validation explain. -- Exact URI lookup, raw download action, and object / publication point export actions. +## Backend requirements -Known backend-dependent limits: +`rpki_query_service` (this repository, `src/bin/rpki_query_service.rs`) with a +query-db built by `rpki_query_indexer`. Some features need extra service flags: -- Parsed projection, raw download, and export success require `rpki_query_service --repo-bytes-db `. -- Without repo bytes, the UI shows explicit unavailable/error states instead of fabricating data. -- VRP IP/prefix lookup is not part of this MVP and is tracked separately. -- Query service does not provide CORS headers; use a same-origin proxy. +| Feature | Requirement | +| --- | --- | +| Parsed projections, raw download, exports | `--repo-bytes-db ` | +| New runs appear automatically | `--watch-run-root ` | + +The service has **no CORS headers and no authentication** — the UI must be +served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse +proxy). VRP IP/prefix/ASN lookup is not available yet (backend feature #070); +the search page says so explicitly when such a query is detected. ## Development -Start query service first: +Start the query service (example with a local query-db): ```bash -rpki_2/rpki/target/llvm-cov-target/debug/rpki_query_service \ - --query-db .scratch/rpki_explorer_m3_api/query-db \ - --listen 127.0.0.1:19571 +rpki_2/rpki/target/debug/rpki_query_service \ + --query-db /path/to/query-db \ + --repo-bytes-db /path/to/repo-bytes.db \ + --listen 127.0.0.1:9557 ``` -Start the Vite dev server with a proxy: +Start the Vite dev server: ```bash cd rpki_2/rpki/ui/rpki-explorer npm install -RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run dev -- --port 5173 +RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run dev ``` -The Vite dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`; default target is `http://127.0.0.1:9557`. +The dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET` +(default `http://127.0.0.1:9557`) and serves the UI on `http://127.0.0.1:5173`. -## Production Build Preview +## Production ```bash -cd rpki_2/rpki/ui/rpki-explorer npm run build -RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run preview -- --port 4173 +RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run preview # smoke-test the build ``` -For real deployment, serve `dist/` through a static server or reverse proxy and route `/api/v1/*` to `rpki_query_service` on the same origin. +For real deployments serve `dist/` with any static server / reverse proxy and +forward `/api/v1/*` to `rpki_query_service` on the same origin, e.g. nginx: + +```nginx +location /api/v1/ { proxy_pass http://127.0.0.1:9557; } +location / { root /srv/rpki-explorer/dist; try_files $uri /index.html; } +``` + +`try_files ... /index.html` (SPA fallback) is required because routing is +client-side. ## Validation ```bash -npm run typecheck -npm run lint -npm run build -npm audit --audit-level=high -npm run test:e2e +npm run typecheck # tsc project references +npm run lint # eslint +npm test # vitest unit tests (lib + api contract) +npm run build # production build +npm run test:e2e # Playwright, fully mocked backend (no service needed) ``` -Playwright is intentionally configured with `workers: 1`. The current query service object endpoints use lazy `report.json` scans, so parallel browser tests can create artificial backend contention and false failures. +E2E tests intercept every `/api/v1/**` request with a fixture-backed mock +(`tests/e2e/mockApi.ts`), so they are deterministic and need no running query +service. They start the Vite dev server automatically (`workers: 1`). -## Screenshot Evidence - -Milestone screenshots are stored under: +## Directory layout ```text -specs/develop/20260617/m*_playwright/ -``` - -## Directory Layout - -```text -src/api/ query-service client and TypeScript records -src/app/ app shell and top-level navigation state -src/components/layout/ sidebar/topbar shell -src/features/overview/ latest run overview dashboard -src/features/repositories repo -> PP -> object browser -src/features/object-detail live object detail and workflows -src/styles/globals.css MVP visual system +src/api/ fetch client, zod schemas, typed endpoint functions +src/app/ providers, router, react-query client +src/components/ shell, data table, pills, pager, tabs, object table, … +src/lib/ formatting, cursor pager state machine, run context, guards +src/pages/ one file per route +src/styles/ design tokens + base/shell/components/pages CSS +tests/e2e/ Playwright specs + fixture-backed API mock ``` diff --git a/ui/rpki-explorer/package-lock.json b/ui/rpki-explorer/package-lock.json index 16ee8f4..0df1122 100644 --- a/ui/rpki-explorer/package-lock.json +++ b/ui/rpki-explorer/package-lock.json @@ -1,17 +1,14 @@ { "name": "rpki-explorer", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rpki-explorer", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@tanstack/react-query": "^5.81.5", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "@xyflow/react": "^12.8.2", "lucide-react": "^0.468.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -22,8 +19,6 @@ "devDependencies": { "@eslint/js": "^9.29.0", "@playwright/test": "^1.53.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", "@types/node": "^24.0.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -38,50 +33,6 @@ "vitest": "^4.1.9" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -747,142 +698,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "license": "MIT", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.14.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz", - "integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.17.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.17.1", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", - "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -894,14 +709,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -925,15 +732,6 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -964,12 +762,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -991,25 +783,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1055,7 +828,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1501,48 +1274,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xyflow/react": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", - "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.77", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "@types/react": ">=17", - "@types/react-dom": ">=17", - "react": ">=17", - "react-dom": ">=17" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.77", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", - "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1583,17 +1314,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1617,16 +1337,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1692,12 +1402,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1769,13 +1473,6 @@ "node": ">= 8" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1804,28 +1501,6 @@ "node": ">=12" } }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -1881,15 +1556,6 @@ "node": ">=12" } }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -1935,41 +1601,6 @@ "node": ">=12" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2001,16 +1632,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2021,14 +1642,6 @@ "node": ">=8" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2450,16 +2063,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2499,14 +2102,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/js-yaml": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", @@ -2880,17 +2475,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2901,16 +2485,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3152,36 +2726,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3311,20 +2855,6 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -3456,19 +2986,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3912,34 +3429,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/ui/rpki-explorer/package.json b/ui/rpki-explorer/package.json index 2513349..bc50307 100644 --- a/ui/rpki-explorer/package.json +++ b/ui/rpki-explorer/package.json @@ -1,6 +1,6 @@ { "name": "rpki-explorer", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { @@ -9,13 +9,11 @@ "typecheck": "tsc -b --pretty false", "lint": "eslint .", "preview": "vite preview --host 127.0.0.1", + "test": "vitest run", "test:e2e": "playwright test" }, "dependencies": { "@tanstack/react-query": "^5.81.5", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "@xyflow/react": "^12.8.2", "lucide-react": "^0.468.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -26,8 +24,6 @@ "devDependencies": { "@eslint/js": "^9.29.0", "@playwright/test": "^1.53.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", "@types/node": "^24.0.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/ui/rpki-explorer/playwright.config.ts b/ui/rpki-explorer/playwright.config.ts index dcf2fab..8d59ebf 100644 --- a/ui/rpki-explorer/playwright.config.ts +++ b/ui/rpki-explorer/playwright.config.ts @@ -3,18 +3,24 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests/e2e", outputDir: "./test-results", - fullyParallel: true, + fullyParallel: false, workers: 1, reporter: [["list"], ["html", { open: "never" }]], use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173", trace: "retain-on-failure", - screenshot: "only-on-failure" + screenshot: "only-on-failure", + }, + webServer: { + command: "npm run dev -- --port 5173 --strictPort", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 60_000, }, projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] } - } - ] + use: { ...devices["Desktop Chrome"] }, + }, + ], }); diff --git a/ui/rpki-explorer/src/api/client.test.ts b/ui/rpki-explorer/src/api/client.test.ts new file mode 100644 index 0000000..032c8c5 --- /dev/null +++ b/ui/rpki-explorer/src/api/client.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { withQuery } from "./client"; +import { + envelopeSchema, + objectInstanceRecordSchema, + runRecordSchema, + searchResultSchema, +} from "./schemas"; + +describe("withQuery", () => { + it("builds a query string skipping empty values", () => { + expect( + withQuery("/api/v1/runs/latest/objects", { + limit: 50, + cursor: null, + type: "roa", + rejected: false, + q: "", + reason: undefined, + }), + ).toBe("/api/v1/runs/latest/objects?limit=50&type=roa&rejected=false"); + }); + + it("URL-encodes values", () => { + expect(withQuery("/x", { uri: "rsync://a/b.cer" })).toBe( + "/x?uri=rsync%3A%2F%2Fa%2Fb.cer", + ); + }); + + it("returns the bare path without params", () => { + expect(withQuery("/x")).toBe("/x"); + expect(withQuery("/x", {})).toBe("/x"); + }); +}); + +describe("envelope + record schemas", () => { + const objectInstance = { + objectInstanceId: "obj-1", + uri: "rsync://repo.example/ca/roa.roa", + sha256: "ab".repeat(32), + objectType: "roa", + result: "ok", + repoId: "repo-1", + ppId: "pp-1", + sourceSection: "fresh", + rejected: false, + }; + + it("parses a list envelope and exposes the cursor", () => { + const parsed = envelopeSchema(objectInstanceRecordSchema.array()).parse({ + data: [objectInstance], + page: { nextCursor: "r1:0:50", limit: 50 }, + meta: { runId: "run_0002", schemaVersion: 1 }, + }); + expect(parsed.data).toHaveLength(1); + expect(parsed.page?.nextCursor).toBe("r1:0:50"); + }); + + it("accepts a null meta.runId (healthz and other run-less endpoints)", () => { + const parsed = envelopeSchema(z.object({ status: z.string() })).parse({ + data: { status: "ok" }, + page: null, + meta: { runId: null, schemaVersion: 1 }, + }); + expect(parsed.data.status).toBe("ok"); + expect(parsed.meta?.runId).toBeNull(); + }); + + it("keeps unknown fields (passthrough) for forward compatibility", () => { + const parsed = objectInstanceRecordSchema.parse({ + ...objectInstance, + futureField: { nested: true }, + }); + expect((parsed as Record).futureField).toEqual({ nested: true }); + }); + + it("rejects records missing required identity fields", () => { + expect(() => + objectInstanceRecordSchema.parse({ uri: "rsync://x" }), + ).toThrow(); + }); + + it("parses run records with partial counts", () => { + const run = runRecordSchema.parse({ + runId: "run_0002", + runSeq: 2, + counts: { objects: 10 }, + }); + expect(run.counts?.objects).toBe(10); + expect(run.counts?.vrps).toBeUndefined(); + }); + + it("parses the unified search result", () => { + const result = searchResultSchema.parse({ + objects: [objectInstance], + repos: [], + publicationPoints: [], + }); + expect(result.objects[0]?.objectInstanceId).toBe("obj-1"); + }); +}); diff --git a/ui/rpki-explorer/src/api/client.ts b/ui/rpki-explorer/src/api/client.ts index 4946bea..d3fa836 100644 --- a/ui/rpki-explorer/src/api/client.ts +++ b/ui/rpki-explorer/src/api/client.ts @@ -1,101 +1,101 @@ -export interface ApiEnvelope { - data: T; - page: { nextCursor: string | null; limit: number } | null; - meta: { runId: string | null; schemaVersion: number }; -} +/** + * Low-level fetch helpers for the rpki_query_service HTTP API. + * + * All JSON endpoints share the envelope shape: + * { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null, + * "meta": { "runId": string, "schemaVersion": number } } + * Errors are `{ "error": string }` with a non-2xx status. + */ export class ApiError extends Error { - constructor( - message: string, - readonly status: number - ) { + readonly status: number; + + constructor(status: number, message: string) { super(message); this.name = "ApiError"; + this.status = status; } } -export type JsonQuery = Record; +export type QueryParams = Record< + string, + string | number | boolean | null | undefined +>; -export function withQuery(path: string, query?: JsonQuery): string { - if (!query) { - return path; - } +/** Build a URL with a query string, skipping empty values. */ +export function withQuery(path: string, params?: QueryParams): string { + if (!params) return path; const search = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (value === undefined || value === null) { - continue; - } + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; search.set(key, String(value)); } - const queryString = search.toString(); - return queryString ? `${path}?${queryString}` : path; + const qs = search.toString(); + return qs ? `${path}?${qs}` : path; } -export async function getJson(path: string, query?: JsonQuery): Promise> { - const response = await fetch(withQuery(path, query)); - const body = (await response.json().catch(() => ({}))) as { error?: string }; - if (!response.ok) { - throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status); - } - return normalizeEnvelope(body); -} - -export async function postJson(path: string, body?: unknown): Promise> { - const response = await fetch(path, { - body: JSON.stringify(body ?? {}), - headers: { "content-type": "application/json" }, - method: "POST" - }); - const responseBody = (await response.json().catch(() => ({}))) as { error?: string }; - if (!response.ok) { - throw new ApiError(responseBody.error ?? `Request failed: ${response.status}`, response.status); - } - return normalizeEnvelope(responseBody); -} - -export async function getBinary(path: string): Promise { - const response = await fetch(path); - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { error?: string }; - throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status); - } - return response.blob(); -} - -export function normalizeEnvelope(value: unknown): ApiEnvelope { - if (typeof value !== "object" || value === null) { - return { - data: value as T, - page: null, - meta: { runId: null, schemaVersion: 1 } - }; - } - const record = value as { - data?: T; - page?: { nextCursor?: string | null; limit?: number } | null; - meta?: { runId?: string | null; schemaVersion?: number } | null; - }; - return { - data: record.data as T, - page: record.page - ? { - nextCursor: record.page.nextCursor ?? null, - limit: record.page.limit ?? 0 - } - : null, - meta: { - runId: record.meta?.runId ?? null, - schemaVersion: record.meta?.schemaVersion ?? 1 +async function parseErrorBody(res: Response): Promise { + try { + const body: unknown = await res.json(); + if ( + body !== null && + typeof body === "object" && + "error" in body && + typeof (body as { error: unknown }).error === "string" + ) { + return (body as { error: string }).error; } - }; + } catch { + // fall through to status text + } + return `HTTP ${res.status} ${res.statusText}`.trim(); } -export function normalizeApiError(error: unknown): Error { - if (error instanceof ApiError) { - return error; +/** Fetch JSON from the API, throwing ApiError on non-2xx. */ +export async function apiFetch(path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(path, init); + } catch (err) { + throw new ApiError(0, err instanceof Error ? err.message : "network error"); } - if (error instanceof Error) { - return error; + if (!res.ok) { + throw new ApiError(res.status, await parseErrorBody(res)); } - return new Error("Unknown API error"); + return (await res.json()) as Promise; +} + +/** Fetch binary content (raw object bytes, export tarballs). */ +export async function apiFetchBlob(path: string): Promise { + let res: Response; + try { + res = await fetch(path); + } catch (err) { + throw new ApiError(0, err instanceof Error ? err.message : "network error"); + } + if (!res.ok) { + throw new ApiError(res.status, await parseErrorBody(res)); + } + return res.blob(); +} + +/** POST a JSON body, returning the parsed JSON response. */ +export async function apiPost(path: string, body: unknown): Promise { + return apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); +} + +/** Trigger a browser download for a blob. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); } diff --git a/ui/rpki-explorer/src/api/queryService.ts b/ui/rpki-explorer/src/api/queryService.ts deleted file mode 100644 index 6df22c7..0000000 --- a/ui/rpki-explorer/src/api/queryService.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { getBinary, getJson, postJson } from "./client"; - -export interface ServiceInfo { - service: string; - version: number; -} - -export interface RunRecord { - schemaVersion: number; - runId: string; - runSeq: number | null; - runDir: string; - validationTime: string | null; - syncMode: string | null; - startedAt: string | null; - finishedAt: string | null; - wallMs: number | null; - artifactPaths: Record; - counts: { - publicationPoints: number; - objects: number; - freshObjects: number; - cachedObjects: number; - rejectedObjects: number; - freshRejectedObjects: number; - cachedRejectedObjects: number; - trustAnchors: number; - vrps: number; - aspas: number; - warnings: number; - }; - indexStatus: string; - indexError: string | null; -} - -export interface RepositoryRecord { - schemaVersion: number; - runId: string; - repoId: string; - uri: string; - host: string; - transport: string; - publicationPoints: number; - objects: number; - rejectedObjects: number; - downloadBytes: number | null; - syncDurationMsTotal: number; - phases: Record; - terminalStates: Record; -} - -export interface PublicationPointRecord { - schemaVersion: number; - runId: string; - ppId: string; - repoId: string; - nodeId: number | null; - parentNodeId: number | null; - rsyncBaseUri: string | null; - manifestRsyncUri: string | null; - publicationPointRsyncUri: string | null; - rrdpNotificationUri: string | null; - source: string | null; - repoSyncSource: string | null; - repoSyncPhase: string | null; - repoSyncDurationMs: number | null; - repoSyncError: string | null; - repoTerminalState: string | null; - thisUpdate: string | null; - nextUpdate: string | null; - verifiedAt: string | null; - objects: number; - rejectedObjects: number; - warnings: number; -} - -export interface ObjectInstanceRecord { - schemaVersion: number; - runId: string; - objectInstanceId: string; - uri: string; - uriHash: string; - sha256: string; - objectType: string; - result: string; - detailSummary: string | null; - repoId: string; - ppId: string; - sourceSection: string; - rejected: boolean; - rejectReason: string | null; -} - -export interface ObjectProjectionRecord { - schemaVersion?: number; - objectType?: string; - sha256?: string; - uri?: string; - parseStatus?: string; - errorSummary?: string | null; - projection?: unknown; -} - -export interface ValidationIssueRecord { - stage?: string; - severity?: string; - reasonCode?: string; - summary?: string; -} - -export interface ObjectValidationRecord { - objectInstanceId: string; - uri: string; - sha256: string; - objectType: string; - finalStatus: string; - auditResult: string; - detailSummary: string | null; - rejected: boolean; - rejectReason: string | null; - sourceSection: string; - explainAvailable: boolean; - authoritative: boolean; - fileValidation: { - status: string; - stage: string; - issues: ValidationIssueRecord[]; - detailSummary: string | null; - }; - chainValidation: { - status: string; - stage: string; - issues: ValidationIssueRecord[]; - note?: string; - edgesPage?: { nextCursor: string | null; limit: number }; - }; -} - -export interface ChainEdgeRecord { - relation: string; - fromUri: string; - toUri: string; - toObjectInstanceId: string | null; - toSha256: string | null; - status: string; - evidence: unknown; -} - -export interface ValidationExplainRecord { - schemaVersion: number; - explainVersion: number; - runId: string; - objectInstanceId: string; - uri: string; - sha256: string; - objectType: string; - finalStatus: string; - auditResult: string; - detailSummary: string | null; - authoritative: boolean; - explainMode: string; - generatedAt: string; - parsevalidate: unknown; - chainvalidate: unknown; - chainEdges: ChainEdgeRecord[]; -} - -export interface ObjectUriIndexRecord { - runId: string; - uri: string; - sha256: string; - objectInstanceId: string; - repoId: string; - ppId: string; -} - -export interface ExportJobRecord { - schemaVersion: number; - jobId: string; - runId: string; - scope: string; - repoId: string | null; - ppId: string | null; - status: string; - createdAt: string; - finishedAt: string | null; - outputPath: string | null; - objectCount: number; - bytesWritten: number; - error: string | null; -} - -export type CountsByKey = Record; - -export async function getServiceInfo() { - return getJson("/api/v1"); -} - -export async function getLatestRun() { - return getJson("/api/v1/latest_run"); -} - -export async function getRunSummary(runId: string) { - return getJson>(`/api/v1/runs/${runId}/summary`); -} - -export async function getStatsObjectTypes(runId: string) { - return getJson(`/api/v1/runs/${runId}/stats/object-types`); -} - -export async function getStatsValidation(runId: string) { - return getJson(`/api/v1/runs/${runId}/stats/validation`); -} - -export async function getStatsReasons(runId: string) { - return getJson(`/api/v1/runs/${runId}/stats/reasons`); -} - -export async function getStatsDownloads(runId: string) { - return getJson>(`/api/v1/runs/${runId}/stats/downloads`); -} - -export async function listRepos(runId: string, limit = 5, cursor?: string | null) { - return getJson(`/api/v1/runs/${runId}/repos`, { limit, cursor: cursor ?? undefined }); -} - -export async function listPublicationPointsForRepo(runId: string, repoId: string, limit = 50, cursor?: string | null) { - return getJson(`/api/v1/runs/${runId}/repos/${repoId}/publication-points`, { - limit, - cursor: cursor ?? undefined - }); -} - -export async function listObjectsForPublicationPoint(runId: string, ppId: string, limit = 50, cursor?: string | null) { - return getJson(`/api/v1/runs/${runId}/publication-points/${ppId}/objects`, { - limit, - cursor: cursor ?? undefined - }); -} - -export async function getObject(runId: string, objectInstanceId: string) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}`); -} - -export async function getObjectByUri(runId: string, uri: string) { - return getJson(`/api/v1/runs/${runId}/objects/by-uri`, { uri }); -} - -export async function getObjectParsed(runId: string, objectInstanceId: string) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed`); -} - -export async function getObjectValidation(runId: string, objectInstanceId: string) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation`); -} - -export async function getObjectChain(runId: string, objectInstanceId: string) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/chain`); -} - -export async function listManifestFiles(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/manifest-files`, { - limit, - cursor: cursor ?? undefined - }); -} - -export async function listRevokedCertificates(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) { - return getJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/revoked-certs`, { - limit, - cursor: cursor ?? undefined - }); -} - -export async function explainObjectValidation(runId: string, objectInstanceId: string, forceRefresh = false) { - return postJson(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation/explain`, { - forceRefresh - }); -} - -export async function downloadRawObject(runId: string, objectInstanceId: string) { - return getBinary(`/api/v1/runs/${runId}/objects/${objectInstanceId}/raw`); -} - -export async function createObjectSetExport(runId: string, objectInstanceIds: string[]) { - return postJson(`/api/v1/runs/${runId}/exports`, { - objectInstanceIds, - scope: "object_set" - }); -} - -export async function createPublicationPointExport(runId: string, ppId: string) { - return postJson(`/api/v1/runs/${runId}/exports`, { - ppId, - scope: "publication_point" - }); -} - -export async function getExportJob(runId: string, jobId: string) { - return getJson(`/api/v1/runs/${runId}/exports/${jobId}`); -} diff --git a/ui/rpki-explorer/src/api/schemas.ts b/ui/rpki-explorer/src/api/schemas.ts new file mode 100644 index 0000000..21f2903 --- /dev/null +++ b/ui/rpki-explorer/src/api/schemas.ts @@ -0,0 +1,308 @@ +/** + * Zod schemas describing the rpki_query_service API contract. + * + * Schemas are intentionally `.passthrough()` so additive backend changes do + * not break the UI; fields the UI depends on are typed strictly. + */ +import { z } from "zod"; + +/* ------------------------------------------------------------------ */ +/* Records */ +/* ------------------------------------------------------------------ */ + +export const runCountsSchema = z + .object({ + publicationPoints: z.number().optional(), + objects: z.number().optional(), + freshObjects: z.number().optional(), + cachedObjects: z.number().optional(), + rejectedObjects: z.number().optional(), + freshRejectedObjects: z.number().optional(), + cachedRejectedObjects: z.number().optional(), + trustAnchors: z.number().optional(), + vrps: z.number().optional(), + aspas: z.number().optional(), + warnings: z.number().optional(), + }) + .passthrough(); + +export const runRecordSchema = z + .object({ + schemaVersion: z.number().optional(), + runId: z.string(), + runSeq: z.number().nullish(), + runDir: z.string().optional(), + validationTime: z.string().nullish(), + syncMode: z.string().nullish(), + startedAt: z.string().nullish(), + finishedAt: z.string().nullish(), + wallMs: z.number().nullish(), + maxRssKb: z.number().nullish(), + artifactPaths: z.record(z.string()).optional(), + counts: runCountsSchema.optional(), + indexStatus: z.string().optional(), + indexError: z.string().nullish(), + }) + .passthrough(); + +export const repositoryRecordSchema = z + .object({ + repoId: z.string(), + uri: z.string(), + host: z.string(), + transport: z.string().nullish(), + publicationPoints: z.number().nullish(), + objects: z.number().nullish(), + rejectedObjects: z.number().nullish(), + downloadBytes: z.number().nullish(), + syncDurationMsTotal: z.number().nullish(), + phases: z.record(z.number()).optional(), + terminalStates: z.record(z.number()).optional(), + }) + .passthrough(); + +export const publicationPointRecordSchema = z + .object({ + ppId: z.string(), + repoId: z.string(), + nodeId: z.number().nullish(), + parentNodeId: z.number().nullish(), + rsyncBaseUri: z.string().nullish(), + manifestRsyncUri: z.string().nullish(), + publicationPointRsyncUri: z.string().nullish(), + rrdpNotificationUri: z.string().nullish(), + source: z.string().nullish(), + repoSyncSource: z.string().nullish(), + repoSyncPhase: z.string().nullish(), + repoSyncDurationMs: z.number().nullish(), + repoSyncError: z.string().nullish(), + repoTerminalState: z.string().nullish(), + thisUpdate: z.string().nullish(), + nextUpdate: z.string().nullish(), + verifiedAt: z.string().nullish(), + objects: z.number().nullish(), + rejectedObjects: z.number().nullish(), + warnings: z.number().nullish(), + }) + .passthrough(); + +export const objectInstanceRecordSchema = z + .object({ + objectInstanceId: z.string(), + uri: z.string(), + uriHash: z.string().optional(), + sha256: z.string().optional(), + objectType: z.string(), + result: z.string().nullish(), + detailSummary: z.string().nullish(), + repoId: z.string(), + ppId: z.string(), + sourceSection: z.string().nullish(), + rejected: z.boolean().optional(), + rejectReason: z.string().nullish(), + sizeBytes: z.number().nullish(), + rawAvailable: z.boolean().optional(), + }) + .passthrough(); + +export const chainEdgeRecordSchema = z + .object({ + relation: z.string(), + fromUri: z.string().nullish(), + toUri: z.string().nullish(), + toObjectInstanceId: z.string().nullish(), + toSha256: z.string().nullish(), + status: z.string().nullish(), + evidence: z.unknown().optional(), + }) + .passthrough(); + +/** Sparse record returned by /objects/by-uri (index hit, no lazy scan). */ +export const objectUriIndexRecordSchema = z + .object({ + uri: z.string(), + sha256: z.string(), + objectInstanceId: z.string(), + repoId: z.string(), + ppId: z.string(), + }) + .passthrough(); + +export const exportJobRecordSchema = z + .object({ + jobId: z.string(), + runId: z.string(), + scope: z.string(), + repoId: z.string().nullish(), + ppId: z.string().nullish(), + status: z.string(), + createdAt: z.string().nullish(), + finishedAt: z.string().nullish(), + outputPath: z.string().nullish(), + objectCount: z.number().nullish(), + bytesWritten: z.number().nullish(), + error: z.string().nullish(), + }) + .passthrough(); + +export const projectionRecordSchema = z + .object({ + schemaVersion: z.number().optional(), + sha256: z.string().optional(), + objectType: z.string().optional(), + parseStatus: z.string().optional(), + errorSummary: z.string().nullish(), + projection: z.record(z.unknown()).nullish(), + }) + .passthrough(); + +export const validationIssueSchema = z + .object({ + severity: z.string().nullish(), + reasonCode: z.string().nullish(), + reasonText: z.string().nullish(), + rfcRefs: z.array(z.string()).optional(), + evidence: z.unknown().optional(), + }) + .passthrough(); + +export const validationStageSchema = z + .object({ + status: z.string().nullish(), + stage: z.string().nullish(), + mode: z.string().nullish(), + projectionStatus: z.string().nullish(), + issues: z.array(validationIssueSchema).optional(), + detailSummary: z.string().nullish(), + edgesCount: z.number().nullish(), + note: z.string().nullish(), + }) + .passthrough(); + +export const validationSummarySchema = z + .object({ + finalStatus: z.string().nullish(), + auditResult: z.string().nullish(), + detailSummary: z.string().nullish(), + parsevalidate: validationStageSchema.nullish(), + chainvalidate: validationStageSchema.nullish(), + }) + .passthrough(); + +export const explainRecordSchema = z + .object({ + finalStatus: z.string().nullish(), + auditResult: z.string().nullish(), + authoritative: z.boolean().optional(), + explainMode: z.string().nullish(), + parsevalidate: validationStageSchema.nullish(), + chainvalidate: validationStageSchema.nullish(), + chainEdges: z.array(chainEdgeRecordSchema).optional(), + }) + .passthrough(); + +export const searchResultSchema = z + .object({ + objects: z.array(objectInstanceRecordSchema), + repos: z.array(repositoryRecordSchema), + publicationPoints: z.array(publicationPointRecordSchema), + }) + .passthrough(); + +export const healthSchema = z + .object({ + status: z.string(), + service: z.string().optional(), + version: z.number().optional(), + latestReadyRun: z.string().nullish(), + }) + .passthrough(); + +export const serviceInfoSchema = z + .object({ + service: z.string(), + version: z.number(), + }) + .passthrough(); + +export const manifestFileEntrySchema = z + .object({ + fileName: z.string(), + hashHex: z.string(), + }) + .passthrough(); + +export const revokedCertEntrySchema = z + .object({ + serialNumberHex: z.string().nullish(), + serialNumber: z.string().nullish(), + revocationDate: z.string().nullish(), + }) + .passthrough(); + +export const runSummarySchema = z + .object({ + publicationPoints: z.number().optional(), + objects: z.number().optional(), + repos: z.number().optional(), + vrps: z.number().optional(), + aspas: z.number().optional(), + }) + .passthrough(); + +export const statsMapSchema = z.record(z.number()); + +/* ------------------------------------------------------------------ */ +/* Envelope */ +/* ------------------------------------------------------------------ */ + +const pageSchema = z.object({ + nextCursor: z.string().nullable(), + limit: z.number(), +}); + +const metaSchema = z + .object({ + runId: z.string().nullish(), + schemaVersion: z.number().optional(), + }) + .passthrough(); + +export function envelopeSchema(dataSchema: T) { + return z + .object({ + data: dataSchema, + page: pageSchema.nullish(), + meta: metaSchema.optional(), + }) + .passthrough(); +} + +/* ------------------------------------------------------------------ */ +/* Inferred types */ +/* ------------------------------------------------------------------ */ + +export type RunCounts = z.infer; +export type RunRecord = z.infer; +export type RepositoryRecord = z.infer; +export type PublicationPointRecord = z.infer; +export type ObjectInstanceRecord = z.infer; +export type ChainEdgeRecord = z.infer; +export type ExportJobRecord = z.infer; +export type ProjectionRecord = z.infer; +export type ValidationIssue = z.infer; +export type ValidationStage = z.infer; +export type ValidationSummary = z.infer; +export type ExplainRecord = z.infer; +export type SearchResult = z.infer; +export type HealthStatus = z.infer; +export type ServiceInfo = z.infer; +export type ManifestFileEntry = z.infer; +export type RevokedCertEntry = z.infer; +export type RunSummary = z.infer; + +export interface ListResult { + items: T[]; + nextCursor: string | null; + runId: string | undefined; +} diff --git a/ui/rpki-explorer/src/api/service.ts b/ui/rpki-explorer/src/api/service.ts new file mode 100644 index 0000000..76623bb --- /dev/null +++ b/ui/rpki-explorer/src/api/service.ts @@ -0,0 +1,450 @@ +/** + * Typed endpoint functions for the rpki_query_service API. + * Every function validates the response envelope with zod before returning. + */ +import { z } from "zod"; +import { apiFetch, apiPost, withQuery, type QueryParams } from "./client"; +import { + chainEdgeRecordSchema, + envelopeSchema, + exportJobRecordSchema, + explainRecordSchema, + healthSchema, + manifestFileEntrySchema, + objectInstanceRecordSchema, + projectionRecordSchema, + publicationPointRecordSchema, + repositoryRecordSchema, + revokedCertEntrySchema, + runRecordSchema, + runSummarySchema, + searchResultSchema, + serviceInfoSchema, + statsMapSchema, + validationSummarySchema, + type ChainEdgeRecord, + type ExportJobRecord, + type ExplainRecord, + type HealthStatus, + type ListResult, + type ManifestFileEntry, + type ObjectInstanceRecord, + type ProjectionRecord, + type PublicationPointRecord, + type RepositoryRecord, + type RevokedCertEntry, + type RunRecord, + type RunSummary, + type SearchResult, + type ServiceInfo, + type ValidationSummary, +} from "./schemas"; + +const API = "/api/v1"; + +function runBase(runId: string): string { + return `${API}/runs/${encodeURIComponent(runId)}`; +} + +async function getData( + path: string, + schema: S, + params?: QueryParams, +): Promise> { + const raw = await apiFetch(withQuery(path, params)); + return envelopeSchema(schema).parse(raw).data; +} + +async function getList( + path: string, + schema: S, + params?: QueryParams, +): Promise>> { + const raw = await apiFetch(withQuery(path, params)); + const parsed = envelopeSchema(z.array(schema)).parse(raw); + return { + items: parsed.data, + nextCursor: parsed.page?.nextCursor ?? null, + runId: parsed.meta?.runId ?? undefined, + }; +} + +/* ------------------------------------------------------------------ */ +/* Service / health */ +/* ------------------------------------------------------------------ */ + +export function getServiceInfo(): Promise { + return getData(`${API}`, serviceInfoSchema); +} + +export function getHealth(): Promise { + return getData(`${API}/healthz`, healthSchema); +} + +/* ------------------------------------------------------------------ */ +/* Runs */ +/* ------------------------------------------------------------------ */ + +export type PageParams = { + limit?: number; + cursor?: string | null; +}; + +export function listRuns(params: PageParams = {}): Promise> { + return getList(`${API}/runs`, runRecordSchema, params); +} + +export function getLatestRun(): Promise { + return getData(`${API}/latest_run`, runRecordSchema); +} + +export function getRun(runId: string): Promise { + return getData(runBase(runId), runRecordSchema); +} + +export function getRunArtifacts(runId: string): Promise> { + return getData(`${runBase(runId)}/artifacts`, z.record(z.string())); +} + +export function getRunSummary(runId: string): Promise { + return getData(`${runBase(runId)}/summary`, runSummarySchema); +} + +/* ------------------------------------------------------------------ */ +/* Repositories */ +/* ------------------------------------------------------------------ */ + +export function listRepos( + runId: string, + params: PageParams = {}, +): Promise> { + return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params); +} + +export function getRepo(runId: string, repoId: string): Promise { + return getData( + `${runBase(runId)}/repos/${encodeURIComponent(repoId)}`, + repositoryRecordSchema, + ); +} + +export interface RepoStats { + publicationPoints?: number; + objects?: number; + rejectedObjects?: number; + syncDurationMsTotal?: number; + phases?: Record; + terminalStates?: Record; +} + +export function getRepoStats(runId: string, repoId: string): Promise { + return getData( + `${runBase(runId)}/repos/${encodeURIComponent(repoId)}/stats`, + z + .object({ + publicationPoints: z.number().optional(), + objects: z.number().optional(), + rejectedObjects: z.number().optional(), + syncDurationMsTotal: z.number().optional(), + phases: z.record(z.number()).optional(), + terminalStates: z.record(z.number()).optional(), + }) + .passthrough(), + ); +} + +/* ------------------------------------------------------------------ */ +/* Publication points */ +/* ------------------------------------------------------------------ */ + +export function listPublicationPoints( + runId: string, + params: PageParams = {}, +): Promise> { + return getList(`${runBase(runId)}/publication-points`, publicationPointRecordSchema, params); +} + +export function listRepoPublicationPoints( + runId: string, + repoId: string, + params: PageParams = {}, +): Promise> { + return getList( + `${runBase(runId)}/repos/${encodeURIComponent(repoId)}/publication-points`, + publicationPointRecordSchema, + params, + ); +} + +export function getPublicationPoint( + runId: string, + ppId: string, +): Promise { + return getData( + `${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}`, + publicationPointRecordSchema, + ); +} + +export interface PpStats { + objects?: number; + rejectedObjects?: number; + warnings?: number; + repoSyncSource?: string; + repoSyncPhase?: string; + repoSyncDurationMs?: number; + repoTerminalState?: string; +} + +export function getPublicationPointStats(runId: string, ppId: string): Promise { + return getData( + `${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/stats`, + z + .object({ + objects: z.number().optional(), + rejectedObjects: z.number().optional(), + warnings: z.number().optional(), + repoSyncSource: z.string().optional(), + repoSyncPhase: z.string().optional(), + repoSyncDurationMs: z.number().optional(), + repoTerminalState: z.string().optional(), + }) + .passthrough(), + ); +} + +/* ------------------------------------------------------------------ */ +/* Objects */ +/* ------------------------------------------------------------------ */ + +export interface ObjectFilters extends PageParams { + type?: string; + result?: string; + rejected?: boolean; + reason?: string; + q?: string; +} + +function filterParams(filters: ObjectFilters): QueryParams { + return { + limit: filters.limit, + cursor: filters.cursor, + type: filters.type, + result: filters.result, + rejected: filters.rejected === undefined ? undefined : String(filters.rejected), + reason: filters.reason, + q: filters.q, + }; +} + +export function listObjects( + runId: string, + filters: ObjectFilters = {}, +): Promise> { + return getList(`${runBase(runId)}/objects`, objectInstanceRecordSchema, filterParams(filters)); +} + +export function listRepoObjects( + runId: string, + repoId: string, + filters: ObjectFilters = {}, +): Promise> { + return getList( + `${runBase(runId)}/repos/${encodeURIComponent(repoId)}/objects`, + objectInstanceRecordSchema, + filterParams(filters), + ); +} + +export function listPublicationPointObjects( + runId: string, + ppId: string, + filters: ObjectFilters = {}, +): Promise> { + return getList( + `${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/objects`, + objectInstanceRecordSchema, + filterParams(filters), + ); +} + +export interface IssueFilters extends PageParams { + reason?: string; + type?: string; + repoId?: string; + ppId?: string; +} + +export function listIssues( + runId: string, + filters: IssueFilters = {}, +): Promise> { + return getList(`${runBase(runId)}/issues`, objectInstanceRecordSchema, { + limit: filters.limit, + cursor: filters.cursor, + reason: filters.reason, + type: filters.type, + repoId: filters.repoId, + ppId: filters.ppId, + }); +} + +export function getObjectByUri( + runId: string, + uri: string, +): Promise { + return getData(`${runBase(runId)}/objects/by-uri`, objectInstanceRecordSchema, { uri }); +} + +export function getObject( + runId: string, + objectInstanceId: string, +): Promise { + return getData( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}`, + objectInstanceRecordSchema, + ); +} + +export function getObjectProjection( + runId: string, + objectInstanceId: string, +): Promise { + return getData( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed`, + projectionRecordSchema.nullable(), + ); +} + +export function getObjectValidation( + runId: string, + objectInstanceId: string, +): Promise { + return getData( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation`, + validationSummarySchema, + ); +} + +export function getObjectChain( + runId: string, + objectInstanceId: string, +): Promise { + return getData( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/chain`, + z.array(chainEdgeRecordSchema), + ); +} + +export function listManifestFiles( + runId: string, + objectInstanceId: string, + params: PageParams = {}, +): Promise> { + return getList( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/manifest-files`, + manifestFileEntrySchema, + params, + ); +} + +export function listRevokedCertificates( + runId: string, + objectInstanceId: string, + params: PageParams = {}, +): Promise> { + return getList( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/revoked-certs`, + revokedCertEntrySchema, + params, + ); +} + +export function explainObjectValidation( + runId: string, + objectInstanceId: string, + options: { forceRefresh?: boolean } = {}, +): Promise { + return apiPost( + `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation/explain`, + { forceRefresh: options.forceRefresh ?? false }, + ).then((raw) => envelopeSchema(explainRecordSchema).parse(raw).data); +} + +export function rawObjectUrl(runId: string, objectInstanceId: string): string { + return `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/raw`; +} + +/* ------------------------------------------------------------------ */ +/* Search */ +/* ------------------------------------------------------------------ */ + +export function searchRun( + runId: string, + q: string, + limit = 10, +): Promise { + return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit }); +} + +/* ------------------------------------------------------------------ */ +/* Stats */ +/* ------------------------------------------------------------------ */ + +export function getStatsObjectTypes(runId: string): Promise> { + return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema); +} + +export function getStatsValidation(runId: string): Promise> { + return getData(`${runBase(runId)}/stats/validation`, statsMapSchema); +} + +export function getStatsReasons(runId: string): Promise> { + return getData(`${runBase(runId)}/stats/reasons`, statsMapSchema); +} + +export function getStatsDownloads(runId: string): Promise> { + return getData(`${runBase(runId)}/stats/downloads`, z.record(z.unknown())); +} + +/* ------------------------------------------------------------------ */ +/* Exports */ +/* ------------------------------------------------------------------ */ + +export interface ExportRequest { + scope: "repo" | "publication_point" | "object_set"; + repoId?: string; + ppId?: string; + objectInstanceIds?: string[]; +} + +export function createExport( + runId: string, + request: ExportRequest, +): Promise { + const body: Record = { scope: request.scope }; + if (request.repoId) body.repoId = request.repoId; + if (request.ppId) body.ppId = request.ppId; + if (request.objectInstanceIds) body.objectInstanceIds = request.objectInstanceIds; + return apiPost(`${runBase(runId)}/exports`, body).then( + (raw) => envelopeSchema(exportJobRecordSchema).parse(raw).data, + ); +} + +export function listExports( + runId: string, + params: PageParams = {}, +): Promise> { + return getList(`${runBase(runId)}/exports`, exportJobRecordSchema, params); +} + +export function getExportJob(runId: string, jobId: string): Promise { + return getData( + `${runBase(runId)}/exports/${encodeURIComponent(jobId)}`, + exportJobRecordSchema, + ); +} + +export function exportDownloadUrl(runId: string, jobId: string): string { + return `${runBase(runId)}/exports/${encodeURIComponent(jobId)}/download`; +} diff --git a/ui/rpki-explorer/src/app/App.tsx b/ui/rpki-explorer/src/app/App.tsx index 4d571c4..d268d13 100644 --- a/ui/rpki-explorer/src/app/App.tsx +++ b/ui/rpki-explorer/src/app/App.tsx @@ -1,103 +1,48 @@ -import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; -import { useState } from "react"; -import { Shell } from "../components/layout/Shell"; -import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage"; -import { OverviewPage } from "../features/overview/OverviewPage"; -import { RepositoriesPage } from "../features/repositories/RepositoriesPage"; -import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState"; +import { Suspense, lazy } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { Shell } from "../components/Shell"; +import { LoadingBlock } from "../components/StateBlock"; -type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api"; - -const navigationItems = [ - { id: "overview", label: "Overview", icon: Home }, - { id: "repositories", label: "Repositories", icon: Database }, - { id: "publication-points", label: "Publication Points", icon: GitBranch }, - { id: "objects", label: "Objects", icon: Braces }, - { id: "validation", label: "Validation", icon: ShieldCheck }, - { id: "exports", label: "Exports", icon: PackageOpen }, - { id: "runs", label: "Runs", icon: Activity }, - { id: "api", label: "API", icon: FileSearch } -] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>; - -const comingSoonCopy: Record, { title: string; description: string; alternative: string }> = { - "publication-points": { - title: "Publication Points", - description: "Dedicated publication point analytics are not implemented yet.", - alternative: "Use Repositories to drill down from repository to publication point and object rows." - }, - validation: { - title: "Validation", - description: "A dedicated validation investigation workspace is planned but not available in this MVP.", - alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence." - }, - exports: { - title: "Exports", - description: "A standalone export job browser is planned but not available yet.", - alternative: "Use Object Detail to trigger object or publication point export workflows." - }, - runs: { - title: "Runs", - description: "Historical run navigation is not implemented in the current Explorer UI.", - alternative: "The current view always uses the latest indexed run from query service." - }, - api: { - title: "API", - description: "The API reference page is planned but not implemented yet.", - alternative: "Use the project README and query service endpoints while the UI reference page is pending." - } -}; +const OverviewPage = lazy(() => import("../pages/OverviewPage")); +const RepositoriesPage = lazy(() => import("../pages/RepositoriesPage")); +const RepositoryDetailPage = lazy(() => import("../pages/RepositoryDetailPage")); +const PublicationPointsPage = lazy(() => import("../pages/PublicationPointsPage")); +const PublicationPointDetailPage = lazy( + () => import("../pages/PublicationPointDetailPage"), +); +const ObjectsPage = lazy(() => import("../pages/ObjectsPage")); +const ObjectDetailPage = lazy(() => import("../pages/ObjectDetailPage")); +const ValidationPage = lazy(() => import("../pages/ValidationPage")); +const SearchPage = lazy(() => import("../pages/SearchPage")); +const ExportsPage = lazy(() => import("../pages/ExportsPage")); +const RunsPage = lazy(() => import("../pages/RunsPage")); +const ApiStatusPage = lazy(() => import("../pages/ApiStatusPage")); +const NotFoundPage = lazy(() => import("../pages/NotFoundPage")); export function App() { - const [activeView, setActiveView] = useState("overview"); - const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState(null); - const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState); - - const openObject = (objectInstanceId: string) => { - setSelectedObjectInstanceId(objectInstanceId); - setActiveView("objects"); - }; - - const renderActiveView = () => { - if (activeView === "objects") { - return ; - } - if (activeView === "repositories") { - return ( - - ); - } - if (activeView === "overview") { - return ; - } - return ; - }; - return ( - - {renderActiveView()} + + }> + + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); } - -function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) { - return ( -
-
-
-

Planned workspace

-

{title}

-

{description}

-
-
- Coming soon - {alternative} -
-
-
- ); -} diff --git a/ui/rpki-explorer/src/app/providers.tsx b/ui/rpki-explorer/src/app/providers.tsx index 2370cec..50a9195 100644 --- a/ui/rpki-explorer/src/app/providers.tsx +++ b/ui/rpki-explorer/src/app/providers.tsx @@ -1,7 +1,12 @@ import { QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; import type { PropsWithChildren } from "react"; import { queryClient } from "./queryClient"; export function AppProviders({ children }: PropsWithChildren) { - return {children}; + return ( + + {children} + + ); } diff --git a/ui/rpki-explorer/src/app/queryClient.ts b/ui/rpki-explorer/src/app/queryClient.ts index f29ac8e..8191c01 100644 --- a/ui/rpki-explorer/src/app/queryClient.ts +++ b/ui/rpki-explorer/src/app/queryClient.ts @@ -1,15 +1,22 @@ import { QueryClient } from "@tanstack/react-query"; +import { ApiError } from "../api/client"; export const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 10_000, + staleTime: 30_000, gcTime: 10 * 60_000, - retry: 1, - refetchOnWindowFocus: false + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + // Never retry client errors (4xx); retry transient failures once. + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false; + } + return failureCount < 1; + }, }, mutations: { - retry: false - } - } + retry: false, + }, + }, }); diff --git a/ui/rpki-explorer/src/components/CopyableValue.tsx b/ui/rpki-explorer/src/components/CopyableValue.tsx new file mode 100644 index 0000000..a9224aa --- /dev/null +++ b/ui/rpki-explorer/src/components/CopyableValue.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState, type MouseEvent } from "react"; +import { Check, Copy } from "lucide-react"; +import { truncateMiddle } from "../lib/format"; + +/** + * Renders a long identifier (URI / hash / id) truncated with an always-available + * copy button. The full value is exposed via the `title` attribute. + */ +export function CopyableValue({ + value, + max = 36, + label = "value", +}: { + value: string | null | undefined; + /** Max rendered characters before middle-truncation. */ + max?: number; + /** Accessible description of what is copied, e.g. "object URI". */ + label?: string; +}) { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, []); + + if (!value) return ; + + const onCopy = async (event: MouseEvent) => { + // Don't trigger row-level click handlers (navigation) when copying. + event.stopPropagation(); + try { + await navigator.clipboard.writeText(value); + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API unavailable (permissions, non-secure context) — select + // fallback is intentionally omitted; the title attribute exposes the + // full value for manual copying. + setCopied(false); + } + }; + + return ( + + {truncateMiddle(value, max)} + + + ); +} diff --git a/ui/rpki-explorer/src/components/CursorPagerControls.tsx b/ui/rpki-explorer/src/components/CursorPagerControls.tsx new file mode 100644 index 0000000..aa9a771 --- /dev/null +++ b/ui/rpki-explorer/src/components/CursorPagerControls.tsx @@ -0,0 +1,48 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +/** + * Previous/Next controls for cursor-paged lists. + */ +export function CursorPagerControls({ + page, + canPrev, + hasNext, + loading, + onPrev, + onNext, + itemCount, +}: { + page: number; + canPrev: boolean; + hasNext: boolean; + loading?: boolean; + onPrev: () => void; + onNext: () => void; + itemCount?: number; +}) { + return ( +
+ + Page {page} + {itemCount !== undefined ? ` · ${itemCount} rows` : ""} + {loading ? " · loading…" : ""} + + + +
+ ); +} diff --git a/ui/rpki-explorer/src/components/DataTable.tsx b/ui/rpki-explorer/src/components/DataTable.tsx new file mode 100644 index 0000000..b78c696 --- /dev/null +++ b/ui/rpki-explorer/src/components/DataTable.tsx @@ -0,0 +1,104 @@ +import type { ReactNode } from "react"; +import { EmptyBlock, ErrorBlock } from "./StateBlock"; + +export interface Column { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Right-align (numeric) cells. */ + numeric?: boolean; + /** Extra class on the td/th. */ + className?: string; +} + +interface DataTableProps { + columns: Column[]; + rows: T[]; + rowKey: (row: T) => string; + loading?: boolean; + error?: unknown; + onRetry?: () => void; + emptyTitle?: string; + emptyHint?: string; + caption?: string; + /** Row click handler — renders rows as clickable. */ + onRowClick?: (row: T) => void; + /** Number of skeleton rows while loading. */ + skeletonRows?: number; +} + +/** + * Data-dense table with unified loading / error / empty states. + * Renders inside a horizontal-scroll wrapper so narrow viewports never + * overflow the document. + */ +export function DataTable({ + columns, + rows, + rowKey, + loading = false, + error, + onRetry, + emptyTitle = "No rows", + emptyHint, + caption, + onRowClick, + skeletonRows = 6, +}: DataTableProps) { + if (error !== undefined && error !== null) { + return ; + } + + if (loading) { + return ( +
+ {Array.from({ length: skeletonRows }, (_, i) => ( +
+ ))} +
+ ); + } + + if (rows.length === 0) { + return ; + } + + return ( +
+ + {caption ? : null} + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + onRowClick(row) : undefined} + > + {columns.map((col) => ( + + ))} + + ))} + +
{caption}
+ {col.header} +
+ {col.render(row)} +
+
+ ); +} diff --git a/ui/rpki-explorer/src/components/KpiCard.tsx b/ui/rpki-explorer/src/components/KpiCard.tsx new file mode 100644 index 0000000..fe5a867 --- /dev/null +++ b/ui/rpki-explorer/src/components/KpiCard.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +export function KpiCard({ + label, + value, + sub, + tone, + to, +}: { + label: string; + value: ReactNode; + sub?: ReactNode; + tone?: "red" | "amber" | "blue"; + to?: string; +}) { + const body = ( + <> + {label} + {value} + {sub ? {sub} : null} + + ); + const className = `kpi-card${tone ? ` tone-${tone}` : ""}`; + if (to) { + return ( + + {body} + + ); + } + return
{body}
; +} diff --git a/ui/rpki-explorer/src/components/ObjectsTable.tsx b/ui/rpki-explorer/src/components/ObjectsTable.tsx new file mode 100644 index 0000000..fdba333 --- /dev/null +++ b/ui/rpki-explorer/src/components/ObjectsTable.tsx @@ -0,0 +1,218 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import type { ListResult, ObjectInstanceRecord } from "../api/schemas"; +import type { ObjectFilters } from "../api/service"; +import { useCursorPager } from "../lib/cursor"; +import { objectTypeLabel, truncateMiddle } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { CopyableValue } from "./CopyableValue"; +import { CursorPagerControls } from "./CursorPagerControls"; +import { DataTable, type Column } from "./DataTable"; +import { StatusPill } from "./StatusPill"; + +export interface ObjectFilterValues { + type: string; + result: string; + rejectedOnly: boolean; + q: string; +} +export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = { + type: "", + result: "", + rejectedOnly: false, + q: "", +}; + +/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */ +const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"]; +const RESULT_OPTIONS = ["ok", "error", "skipped"]; + +/** + * Filter bar + paginated object table. The `fetcher` determines the scope + * (global / repo / publication point / issues). Filter state is controlled + * by the caller so standalone pages can sync it to the URL. + */ +export function ObjectsTable({ + runId, + queryKeyScope, + fetcher, + filters, + onFiltersChange, + showReason = true, +}: { + runId: string; + queryKeyScope: string; + fetcher: (filters: ObjectFilters) => Promise>; + filters: ObjectFilterValues; + onFiltersChange: (next: ObjectFilterValues) => void; + showReason?: boolean; +}) { + const navigate = useNavigate(); + const [qDraft, setQDraft] = useState(filters.q); + + // Keep the draft input in sync when filters change externally (URL nav). + useEffect(() => { + setQDraft(filters.q); + }, [filters.q]); + const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]); + const pager = useCursorPager(filterKey); + + const query = useQuery({ + queryKey: ["objects", queryKeyScope, runId, filters, pager.cursor], + queryFn: () => + fetcher({ + limit: 50, + cursor: pager.cursor, + type: filters.type || undefined, + result: filters.result || undefined, + rejected: filters.rejectedOnly || undefined, + q: filters.q || undefined, + }), + placeholderData: (prev) => prev, + }); + + const columns: Column[] = useMemo( + () => [ + { + key: "type", + header: "Type", + render: (obj) => {objectTypeLabel(obj.objectType)}, + }, + { + key: "uri", + header: "URI", + render: (obj) => , + }, + { + key: "sha256", + header: "SHA-256", + render: (obj) => ( + + {obj.sha256 ? truncateMiddle(obj.sha256, 18) : "—"} + + ), + }, + { + key: "source", + header: "Source", + render: (obj) => , + }, + { + key: "result", + header: "Result", + render: (obj) => , + }, + ...(showReason + ? [ + { + key: "reason", + header: "Reject reason", + render: (obj: ObjectInstanceRecord) => + obj.rejectReason ? ( + + {truncateMiddle(obj.rejectReason, 60)} + + ) : ( + + ), + } satisfies Column, + ] + : []), + ], + [showReason], + ); + + const submitQ = () => { + if (qDraft.trim() !== filters.q) { + onFiltersChange({ ...filters, q: qDraft.trim() }); + } + }; + + return ( +
+
+
+ + +
+
+ + +
+
+ + setQDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitQ(); + }} + onBlur={submitQ} + placeholder="substring…" + /> +
+ + + server-side filters + +
+ + obj.objectInstanceId} + loading={query.isPending} + error={query.isError ? query.error : undefined} + onRetry={() => query.refetch()} + emptyTitle="No objects match" + emptyHint="Try relaxing the filters." + caption="RPKI objects" + onRowClick={(obj) => + navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId)) + } + /> + pager.goNext(query.data?.nextCursor ?? null)} + itemCount={query.data?.items.length} + /> +
+ ); +} diff --git a/ui/rpki-explorer/src/components/PageHeader.tsx b/ui/rpki-explorer/src/components/PageHeader.tsx new file mode 100644 index 0000000..24d176d --- /dev/null +++ b/ui/rpki-explorer/src/components/PageHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +export function PageHeader({ + title, + subtitle, + actions, +}: { + title: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle ?
{subtitle}
: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/ui/rpki-explorer/src/components/Panel.tsx b/ui/rpki-explorer/src/components/Panel.tsx new file mode 100644 index 0000000..51c428d --- /dev/null +++ b/ui/rpki-explorer/src/components/Panel.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +/** Panel with header (title + optional tools) and body. */ +export function Panel({ + title, + subtitle, + tools, + children, + flush = false, + className = "", +}: { + title?: ReactNode; + subtitle?: ReactNode; + tools?: ReactNode; + children: ReactNode; + /** Remove body padding (for tables that span the full width). */ + flush?: boolean; + className?: string; +}) { + return ( +
+ {title || tools ? ( +
+
+ {title ?

{title}

: null} + {subtitle ?
{subtitle}
: null} +
+ {tools ?
{tools}
: null} +
+ ) : null} +
{children}
+
+ ); +} diff --git a/ui/rpki-explorer/src/components/ProjectionView.tsx b/ui/rpki-explorer/src/components/ProjectionView.tsx new file mode 100644 index 0000000..4b1e736 --- /dev/null +++ b/ui/rpki-explorer/src/components/ProjectionView.tsx @@ -0,0 +1,305 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { listManifestFiles, listRevokedCertificates } from "../api/service"; +import type { ProjectionRecord } from "../api/schemas"; +import { CopyableValue } from "./CopyableValue"; +import { CursorPagerControls } from "./CursorPagerControls"; +import { DataTable, type Column } from "./DataTable"; +import { EmptyBlock, LoadingBlock, Notice } from "./StateBlock"; +import { useCursorPager } from "../lib/cursor"; +import { formatInt, formatUtc } from "../lib/format"; +import { asArray, asNumber, asRecord, asString } from "../lib/projection"; + +function MetaRow({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) { + return ( + <> +
{label}
+
{value ?? }
+ + ); +} + +function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { + const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`); + const query = useQuery({ + queryKey: ["manifest-files", runId, objectInstanceId, pager.cursor], + queryFn: () => listManifestFiles(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const columns: Column<{ fileName: string; hashHex: string }>[] = [ + { key: "file", header: "File", render: (row) => {row.fileName} }, + { + key: "hash", + header: "Hash", + render: (row) => , + }, + ]; + + if (query.isPending) return ; + if (query.isError) return Failed to load the manifest file list.; + return ( + <> + row.fileName} + emptyTitle="Manifest has no file entries" + caption="Manifest file list" + /> + pager.goNext(query.data.nextCursor ?? null)} + itemCount={query.data.items.length} + /> + + ); +} + +function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { + const pager = useCursorPager(`${runId}:${objectInstanceId}:crl`); + const query = useQuery({ + queryKey: ["revoked-certs", runId, objectInstanceId, pager.cursor], + queryFn: () => + listRevokedCertificates(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [ + { + key: "serial", + header: "Serial number", + render: (row) => ( + + ), + }, + { key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) }, + ]; + + if (query.isPending) return ; + if (query.isError) return Failed to load the revocation list.; + const rows = query.data.items.map((row, index) => ({ + ...row, + _key: `${row.serialNumberHex ?? row.serialNumber ?? "entry"}-${index}`, + })); + return ( + <> + row._key} + emptyTitle="No revoked certificates" + caption="Revoked certificates" + /> + pager.goNext(query.data.nextCursor ?? null)} + itemCount={query.data.items.length} + /> + + ); +} + +function RoaView({ projection }: { projection: Record }) { + const roa = asRecord(projection.roa); + const families = asArray(roa?.ipAddressFamilies); + return ( + <> +
+ +
+ {families.map((family, i) => { + const fam = asRecord(family); + const addresses = asArray(fam?.addresses); + return ( +
+

+ Address family {asString(fam?.afi) ?? `#${i + 1}`} +

+ ) => ( + {asString(row.prefix) ?? "—"} + ), + }, + { + key: "maxLength", + header: "maxLength", + numeric: true, + render: (row: Record) => asNumber(row.maxLength) ?? "—", + }, + ]} + rows={addresses.map((a) => asRecord(a) ?? {})} + rowKey={(row) => `${asString(row.prefix)}-${asNumber(row.maxLength)}`} + emptyTitle="No prefixes" + /> +
+ ); + })} + + ); +} + +function AspaView({ projection }: { projection: Record }) { + const aspa = asRecord(projection.aspa); + const providers = asArray(aspa?.providerAsIds).map((p) => asNumber(p) ?? asString(p)); + return ( +
+ + + {providers.map((p, i) => ( + AS{String(p)} + ))} + + ) : ( + "—" + ) + } + /> +
+ ); +} + +function CertificateView({ projection }: { projection: Record }) { + const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection; + const validity = asRecord(cert.validity); + const resources = asRecord(cert.resources); + const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses); + const asBlocks = asArray(resources?.asResources ?? resources?.asns); + return ( +
+ + + + + } /> + } /> + } /> + } /> + } /> + + {ipBlocks.slice(0, 12).map((b, i) => ( + {typeof b === "string" ? b : JSON.stringify(b)} + ))} + {ipBlocks.length > 12 ? +{ipBlocks.length - 12} more : null} + + ) : ( + "—" + ) + } + /> + + {asBlocks.slice(0, 12).map((b, i) => ( + {typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)} + ))} + {asBlocks.length > 12 ? +{asBlocks.length - 12} more : null} + + ) : ( + "—" + ) + } + /> + +
+ ); +} + +/** + * Structured rendering of a parsed object projection, per object type. + */ +export function ProjectionView({ + record, + runId, + objectInstanceId, +}: { + record: ProjectionRecord | null; + runId: string; + objectInstanceId: string; +}) { + if (!record || !record.projection) { + return ( + + ); + } + if (record.parseStatus === "error") { + return Projection failed: {record.errorSummary ?? "unknown parse error"}; + } + + const projection = record.projection; + const type = (record.objectType ?? "").toLowerCase(); + + if (type === "roa") return ; + if (type === "asa" || type === "aspa") return ; + + if (type === "mft") { + const manifest = asRecord(projection.manifest); + return ( + <> +
+ + + + + +
+ + + ); + } + + if (type === "crl") { + const crl = asRecord(projection.crl); + return ( + <> +
+ + + + + } /> +
+ + + ); + } + + if (type === "cer" || type === "ee" || type === "router_cert") { + return ; + } + + return ( +
+      {JSON.stringify(projection, null, 2)}
+    
+ ); +} diff --git a/ui/rpki-explorer/src/components/Shell.tsx b/ui/rpki-explorer/src/components/Shell.tsx new file mode 100644 index 0000000..3853988 --- /dev/null +++ b/ui/rpki-explorer/src/components/Shell.tsx @@ -0,0 +1,176 @@ +import { useState, type FormEvent, type ReactNode } from "react"; +import { NavLink, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Braces, + FileBox, + FolderTree, + History, + LayoutDashboard, + PackageOpen, + PanelLeftClose, + PanelLeftOpen, + Search as SearchIcon, + Server, + ShieldCheck, +} from "lucide-react"; +import { getHealth, listRuns } from "../api/service"; +import { LATEST_RUN, useRunParam, useSetRunParam } from "../lib/run"; +import { StatusPill } from "./StatusPill"; + +const NAV_ITEMS = [ + { path: "/", label: "Overview", icon: LayoutDashboard, end: true }, + { path: "/repositories", label: "Repositories", icon: Server, end: false }, + { path: "/publication-points", label: "Publication Points", icon: FolderTree, end: false }, + { path: "/objects", label: "Objects", icon: FileBox, end: false }, + { path: "/validation", label: "Validation", icon: ShieldCheck, end: false }, + { path: "/exports", label: "Exports", icon: PackageOpen, end: false }, + { path: "/runs", label: "Runs", icon: History, end: false }, + { path: "/api", label: "API", icon: Braces, end: false }, +]; + +const COLLAPSE_KEY = "rpki-explorer.sidebar-collapsed"; + +function ConnectionPill() { + const healthQuery = useQuery({ + queryKey: ["health"], + queryFn: getHealth, + refetchInterval: 60_000, + }); + + if (healthQuery.isPending) { + return ; + } + if (healthQuery.isError) { + return ; + } + const health = healthQuery.data; + return ( + + + + ); +} + +function RunSelector() { + const runParam = useRunParam(); + const setRunParam = useSetRunParam(); + const runsQuery = useQuery({ + queryKey: ["runs", "selector"], + queryFn: () => listRuns({ limit: 25 }), + staleTime: 60_000, + }); + const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth }); + const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId; + + return ( +
+ + +
+ ); +} + +function TopbarSearch() { + const navigate = useNavigate(); + const runParam = useRunParam(); + const [value, setValue] = useState(""); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const q = value.trim(); + if (!q) return; + const params = new URLSearchParams({ q }); + if (runParam) params.set("run", runParam); + navigate(`/search?${params.toString()}`); + }; + + return ( +
+ + setValue(event.target.value)} + placeholder="Search URI, SHA-256, repository host…" + aria-label="Global search" + /> +
+ ); +} + +export function Shell({ children }: { children: ReactNode }) { + const runParam = useRunParam(); + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem(COLLAPSE_KEY) === "1", + ); + + const toggleCollapsed = () => { + setCollapsed((prev) => { + localStorage.setItem(COLLAPSE_KEY, prev ? "0" : "1"); + return !prev; + }); + }; + + const search = runParam ? `?run=${encodeURIComponent(runParam)}` : ""; + + return ( +
+
+ + RPKI Explorer +
+ +
+ + + +
+ + + +
{children}
+
+ ); +} diff --git a/ui/rpki-explorer/src/components/StateBlock.tsx b/ui/rpki-explorer/src/components/StateBlock.tsx new file mode 100644 index 0000000..e74006b --- /dev/null +++ b/ui/rpki-explorer/src/components/StateBlock.tsx @@ -0,0 +1,73 @@ +import { AlertTriangle, Inbox, RefreshCw } from "lucide-react"; +import { ApiError } from "../api/client"; + +export function LoadingBlock({ label = "Loading…" }: { label?: string }) { + return ( +
+
+ ); +} + +export function ErrorBlock({ + error, + onRetry, + title = "Failed to load data", +}: { + error: unknown; + onRetry?: () => void; + title?: string; +}) { + const message = + error instanceof ApiError + ? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}` + : error instanceof Error + ? error.message + : "Unknown error"; + return ( +
+
+ ); +} + +export function EmptyBlock({ + title = "No data", + hint, +}: { + title?: string; + hint?: string; +}) { + return ( +
+
+ ); +} + +/** Inline warning/info banner. */ +export function Notice({ + kind, + children, +}: { + kind: "info" | "warning" | "error"; + children: React.ReactNode; +}) { + return ( +
+
+ ); +} diff --git a/ui/rpki-explorer/src/components/StatusPill.tsx b/ui/rpki-explorer/src/components/StatusPill.tsx new file mode 100644 index 0000000..8d17b04 --- /dev/null +++ b/ui/rpki-explorer/src/components/StatusPill.tsx @@ -0,0 +1,54 @@ +/** + * Status pill with a deterministic status → tone mapping. The label is + * always rendered as text (status never relies on color alone). + */ + +type Tone = "green" | "amber" | "red" | "blue" | "slate"; + +const TONE_BY_STATUS: Record = { + ok: "green", + valid: "green", + ready: "green", + complete: "green", + fresh: "green", + success: "green", + linked: "green", + connected: "green", + running: "blue", + building: "amber", + warnings: "amber", + warning: "amber", + cached: "amber", + partial: "amber", + stale: "amber", + error: "red", + invalid: "red", + failed: "red", + rejected: "red", + failed_no_cache: "red", + missing: "red", + skipped: "slate", + unknown: "slate", + pending: "slate", +}; + +export function toneForStatus(status: string | null | undefined): Tone { + if (!status) return "slate"; + return TONE_BY_STATUS[status.toLowerCase()] ?? "slate"; +} + +export function StatusPill({ + status, + label, +}: { + status: string | null | undefined; + label?: string; +}) { + const text = label ?? status ?? "unknown"; + return ( + + + ); +} diff --git a/ui/rpki-explorer/src/components/Tabs.tsx b/ui/rpki-explorer/src/components/Tabs.tsx new file mode 100644 index 0000000..a648945 --- /dev/null +++ b/ui/rpki-explorer/src/components/Tabs.tsx @@ -0,0 +1,79 @@ +import { useRef } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; + +export interface TabItem { + id: string; + label: ReactNode; +} + +/** + * Accessible tab strip with roving tabindex and arrow-key navigation. + * Controlled: the parent owns the active id (usually synced to the URL). + */ +export function Tabs({ + tabs, + active, + onChange, + ariaLabel, +}: { + tabs: TabItem[]; + active: string; + onChange: (id: string) => void; + ariaLabel: string; +}) { + const refs = useRef(new Map()); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; + const index = tabs.findIndex((t) => t.id === active); + if (index < 0) return; + const delta = event.key === "ArrowRight" ? 1 : -1; + const next = tabs[(index + delta + tabs.length) % tabs.length]; + event.preventDefault(); + onChange(next.id); + refs.current.get(next.id)?.focus(); + }; + + return ( +
+ {tabs.map((tab) => { + const selected = tab.id === active; + return ( + + ); + })} +
+ ); +} + +export function TabPanel({ + id, + active, + children, +}: { + id: string; + active: string; + children: ReactNode; +}) { + if (id !== active) return null; + return ( +
+ {children} +
+ ); +} diff --git a/ui/rpki-explorer/src/components/WorkflowStatus.tsx b/ui/rpki-explorer/src/components/WorkflowStatus.tsx new file mode 100644 index 0000000..92eac33 --- /dev/null +++ b/ui/rpki-explorer/src/components/WorkflowStatus.tsx @@ -0,0 +1,44 @@ +import { CheckCircle2, XCircle } from "lucide-react"; +import type { ExportJobRecord } from "../api/schemas"; +import { exportDownloadUrl } from "../api/service"; +import { formatInt } from "../lib/format"; + +/** + * Presentational status line for an async export job. Polling is the + * caller's responsibility (react-query `refetchInterval`). + */ +export function WorkflowStatus({ + job, + runId, +}: { + job: ExportJobRecord; + runId: string; +}) { + if (job.status === "complete") { + return ( + + + ); + } + if (job.status === "failed") { + return ( + + + ); + } + return ( + + + ); +} diff --git a/ui/rpki-explorer/src/components/common/CopyableValue.tsx b/ui/rpki-explorer/src/components/common/CopyableValue.tsx deleted file mode 100644 index c659830..0000000 --- a/ui/rpki-explorer/src/components/common/CopyableValue.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Check, Copy } from "lucide-react"; -import { useEffect, useState } from "react"; - -interface CopyableValueProps { - className?: string; - displayValue?: string; - label: string; - value: string; -} - -interface TooltipPosition { - left: number; - top: number; -} - -export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) { - const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); - const [tooltipPosition, setTooltipPosition] = useState(null); - - useEffect(() => { - if (copyState === "idle") { - return; - } - const timeout = window.setTimeout(() => setCopyState("idle"), 1600); - return () => window.clearTimeout(timeout); - }, [copyState]); - - const showTooltip = (target: HTMLElement) => { - const rect = target.getBoundingClientRect(); - setTooltipPosition({ - left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)), - top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10 - }); - }; - - const copy = async () => { - try { - await copyText(value); - setCopyState("copied"); - } catch { - setCopyState("failed"); - } - }; - - return ( - - setTooltipPosition(null)} - onFocus={(event) => showTooltip(event.currentTarget)} - onMouseEnter={(event) => showTooltip(event.currentTarget)} - onMouseLeave={() => setTooltipPosition(null)} - tabIndex={0} - title={value} - > - {displayValue ?? value} - - - {tooltipPosition ? ( - - {value} - - ) : null} - {copyState !== "idle" ? {copyState === "copied" ? "Copied" : "Copy failed"} : null} - - ); -} - -async function copyText(value: string) { - if (navigator.clipboard) { - await navigator.clipboard.writeText(value); - return; - } - const textArea = document.createElement("textarea"); - textArea.value = value; - textArea.style.position = "fixed"; - textArea.style.opacity = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - document.execCommand("copy"); - } finally { - document.body.removeChild(textArea); - } -} diff --git a/ui/rpki-explorer/src/components/common/CursorPager.tsx b/ui/rpki-explorer/src/components/common/CursorPager.tsx deleted file mode 100644 index e8265f5..0000000 --- a/ui/rpki-explorer/src/components/common/CursorPager.tsx +++ /dev/null @@ -1,32 +0,0 @@ -interface CursorPagerProps { - label?: string; - isFetching: boolean; - nextCursor: string | null; - onNext: () => void; - onPrevious: () => void; - pageNumber: number; - previousCount: number; - rangeLabel?: string; -} - -export function CursorPager({ - label, - isFetching, - nextCursor, - onNext, - onPrevious, - pageNumber, - previousCount, - rangeLabel -}: CursorPagerProps) { - return ( -
- - - {label ?? `Page ${pageNumber}`} - {rangeLabel ? {rangeLabel} : null} - - -
- ); -} diff --git a/ui/rpki-explorer/src/components/common/cursorPaging.ts b/ui/rpki-explorer/src/components/common/cursorPaging.ts deleted file mode 100644 index 3b547a9..0000000 --- a/ui/rpki-explorer/src/components/common/cursorPaging.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface PageState { - cursor: string | null; - previous: Array; -} - -export function advancePage(page: PageState, nextCursor: string | null): PageState { - if (!nextCursor) { - return page; - } - return { - cursor: nextCursor, - previous: [...page.previous, page.cursor] - }; -} - -export function previousPage(page: PageState): PageState { - if (page.previous.length === 0) { - return page; - } - return { - cursor: page.previous[page.previous.length - 1] ?? null, - previous: page.previous.slice(0, -1) - }; -} diff --git a/ui/rpki-explorer/src/components/layout/Shell.tsx b/ui/rpki-explorer/src/components/layout/Shell.tsx deleted file mode 100644 index 35e8257..0000000 --- a/ui/rpki-explorer/src/components/layout/Shell.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { LucideIcon } from "lucide-react"; -import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react"; -import type { PropsWithChildren } from "react"; -import { useState } from "react"; - -export interface NavigationItem { - id: Id; - label: string; - icon: LucideIcon; -} - -interface ShellProps extends PropsWithChildren { - activeView: Id; - navigationItems: Array>; - onNavigate: (id: Id) => void; -} - -export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - - return ( -
- -
-
-
- Run - latest indexed run -
-
- -
UI Ready
-
-
- {children} -
-
- ); -} diff --git a/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx b/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx deleted file mode 100644 index 9520734..0000000 --- a/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx +++ /dev/null @@ -1,509 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { normalizeApiError } from "../../api/client"; -import { CopyableValue } from "../../components/common/CopyableValue"; -import { - createObjectSetExport, - createPublicationPointExport, - downloadRawObject, - explainObjectValidation, - getLatestRun, - getObject, - getObjectByUri, - getObjectChain, - getObjectParsed, - getObjectValidation, - listManifestFiles, - listRevokedCertificates, - type ChainEdgeRecord, - type ObjectInstanceRecord, - type ObjectValidationRecord, - type ValidationExplainRecord -} from "../../api/queryService"; - -const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const; -type ObjectTab = (typeof tabs)[number]; - -interface ObjectDetailPageProps { - initialObjectInstanceId?: string | null; -} - -export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) { - const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState(initialObjectInstanceId ?? null); - const [activeTab, setActiveTab] = useState("Validation"); - const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); - const [searchUri, setSearchUri] = useState(""); - const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun }); - const runId = latestRunQuery.data?.data.runId ?? "latest"; - useEffect(() => { - if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) { - setSelectedObjectInstanceId(initialObjectInstanceId); - setActiveTab("Validation"); - } - }, [initialObjectInstanceId, selectedObjectInstanceId]); - - const objectQuery = useQuery({ - enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - queryKey: ["object-detail-object", runId, selectedObjectInstanceId], - queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""), - staleTime: 5 * 60 * 1000 - }); - const selectedObject = objectQuery.data?.data ?? null; - const validationQuery = useQuery({ - enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - queryKey: ["object-detail-validation", runId, selectedObjectInstanceId], - queryFn: () => getObjectValidation(runId, selectedObjectInstanceId ?? ""), - staleTime: 5 * 60 * 1000 - }); - const parsedQuery = useQuery({ - enabled: activeTab === "Parsed" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - queryKey: ["object-detail-parsed", runId, selectedObjectInstanceId], - queryFn: () => getObjectParsed(runId, selectedObjectInstanceId ?? ""), - staleTime: 5 * 60 * 1000 - }); - const chainQuery = useQuery({ - enabled: activeTab === "Chain" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - queryKey: ["object-detail-chain", runId, selectedObjectInstanceId], - queryFn: () => getObjectChain(runId, selectedObjectInstanceId ?? ""), - staleTime: 5 * 60 * 1000 - }); - const manifestFilesQuery = useQuery({ - enabled: activeTab === "Manifest files" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - retry: false, - queryKey: ["object-detail-manifest-files", runId, selectedObjectInstanceId], - queryFn: () => listManifestFiles(runId, selectedObjectInstanceId ?? "", 50), - staleTime: 5 * 60 * 1000 - }); - const revokedCertsQuery = useQuery({ - enabled: activeTab === "Revoked certs" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), - retry: false, - queryKey: ["object-detail-revoked-certs", runId, selectedObjectInstanceId], - queryFn: () => listRevokedCertificates(runId, selectedObjectInstanceId ?? "", 50), - staleTime: 5 * 60 * 1000 - }); - const explainMutation = useMutation({ - mutationFn: () => explainObjectValidation(runId, selectedObjectInstanceId ?? "") - }); - const uriSearchMutation = useMutation({ - mutationFn: (uri: string) => getObjectByUri(runId, uri), - onSuccess: (result) => { - setSelectedObjectInstanceId(result.data.objectInstanceId); - setActiveTab("Validation"); - } - }); - const rawDownloadMutation = useMutation({ - mutationFn: () => downloadRawObject(runId, selectedObjectInstanceId ?? "") - }); - const objectExportMutation = useMutation({ - mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : []) - }); - const ppExportMutation = useMutation({ - mutationFn: () => { - if (!selectedObject?.ppId) { - throw new Error("No publication point selected"); - } - return createPublicationPointExport(runId, selectedObject.ppId); - } - }); - - useEffect(() => { - if (copyState === "idle") { - return; - } - const timeout = window.setTimeout(() => setCopyState("idle"), 1800); - return () => window.clearTimeout(timeout); - }, [copyState]); - - const copySelectedUri = async () => { - if (!selectedObject?.uri) { - return; - } - try { - await navigator.clipboard.writeText(selectedObject.uri); - setCopyState("copied"); - } catch { - setCopyState("failed"); - } - }; - - const validation = validationQuery.data?.data ?? null; - const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error; - - return ( -
- {apiError ? : null} -
- -
-
- ); -} - -function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) { - return ( -
- - - - - - -
- ); -} - -function WorkflowStatus({ - objectExport, - objectExportError, - ppExport, - ppExportError, - rawError, - rawPending, - searchError, - searchPending -}: { - objectExport: { jobId?: string; status?: string } | null; - objectExportError: unknown; - ppExport: { jobId?: string; status?: string } | null; - ppExportError: unknown; - rawError: unknown; - rawPending: boolean; - searchError: unknown; - searchPending: boolean; -}) { - const lines = [ - searchPending ? "Searching object URI..." : null, - searchError ? `URI search failed: ${normalizeApiError(searchError).message}` : null, - rawPending ? "Downloading raw object..." : null, - rawError ? `Raw download failed: ${normalizeApiError(rawError).message}` : null, - objectExport ? `Object export job ${objectExport.jobId ?? ""} ${objectExport.status ?? ""}`.trim() : null, - objectExportError ? `Object export failed: ${normalizeApiError(objectExportError).message}` : null, - ppExport ? `PP export job ${ppExport.jobId ?? ""} ${ppExport.status ?? ""}`.trim() : null, - ppExportError ? `PP export failed: ${normalizeApiError(ppExportError).message}` : null - ].filter((line): line is string => Boolean(line)); - if (lines.length === 0) { - return null; - } - return ( -
- -
    - {lines.map((line) => ( -
  • {line}
  • - ))} -
-
- ); -} - -function ParsedPanel({ parsed, isFetching, error }: { parsed: unknown; isFetching: boolean; error: unknown }) { - if (isFetching) { - return ; - } - if (error) { - return ; - } - if (!parsed) { - return ; - } - return ( -
- } /> - -
- ); -} - -function ValidationPanel({ validation, isFetching, explain, explainPending, onExplain, explainError }: { - validation: ObjectValidationRecord | null; - isFetching: boolean; - explain: ValidationExplainRecord | null; - explainPending: boolean; - onExplain: () => void; - explainError: unknown; -}) { - if (isFetching) { - return ; - } - return ( -
- } /> -
- - - -
-
- - Explain is an audit projection, not full authoritative revalidation. -
- {explainError ?
Explain failed: {normalizeApiError(explainError).message}
: null} - {explain ? ( -
- - - - -
- ) : null} -
- ); -} - -function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; isFetching: boolean; error: unknown }) { - if (isFetching) { - return ; - } - if (error) { - return ; - } - return ( -
- } /> - {edges.length === 0 ?
No chain edges recorded for this object.
: null} -
- {edges.map((edge) => ( -
- {edge.relation} - {edge.status} - -
- ))} -
-
- ); -} - -function ArrayPanel({ title, rows, isFetching, error }: { title: string; rows: unknown[]; isFetching: boolean; error: unknown }) { - if (isFetching) { - return ; - } - if (error) { - return ; - } - return ( -
- - {rows.length === 0 ?
No rows returned for this object.
: null} - {rows.length > 0 ? : null} -
- ); -} - -function LoadingPanel({ title, label }: { title: string; label: string }) { - return ( -
- - -
- ); -} - -function NoticePanel({ title, message }: { title: string; message: string }) { - return ( -
- -
{message}
-
- ); -} - -function PanelHeading({ eyebrow, title, icon }: { eyebrow: string; title: string; icon?: React.ReactNode }) { - return ( -
-
-

{eyebrow}

-

{title}

-
- {icon} -
- ); -} - -function ValidationCheck({ label, status, note }: { label: string; status: string; note: string }) { - const ok = status === "valid" || status === "ok"; - return ( -
- -
- {label}: {status} -

{note}

-
-
- ); -} - -function JsonPreview({ value }: { value: unknown }) { - const text = useMemo(() => JSON.stringify(value, null, 2), [value]); - return
{text}
; -} - -function ErrorBanner({ error }: { error: unknown }) { - return ( -
- Object API unavailable - {normalizeApiError(error).message} -
- ); -} - -function StatusPill({ state }: { state: string }) { - const normalized = state.toLowerCase(); - const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error") || normalized.includes("invalid") - ? "warning" - : normalized.includes("cache") || normalized.includes("fallback") - ? "fallback" - : "healthy"; - return {state}; -} - -function LoadingLine({ label }: { label: string }) { - return ( -
- - {label} -
- ); -} - -function Meta({ copyable, label, value }: { copyable?: boolean; label: string; value: string }) { - return ( -
- {label} - {copyable ? : value} -
- ); -} - -function labelObjectType(type: string) { - const labels: Record = { - certificate: "CER", - crl: "CRL", - manifest: "MFT", - roa: "ROA", - aspa: "ASPA" - }; - return labels[type] ?? type.toUpperCase(); -} - -function tabId(tab: ObjectTab) { - return tab.toLowerCase().replaceAll(" ", "-"); -} - -function issuesText(issues: { summary?: string }[] | undefined) { - if (!issues || issues.length === 0) { - return null; - } - return issues.map((issue) => issue.summary).filter(Boolean).join("; "); -} diff --git a/ui/rpki-explorer/src/features/overview/OverviewPage.tsx b/ui/rpki-explorer/src/features/overview/OverviewPage.tsx deleted file mode 100644 index 33bdc7c..0000000 --- a/ui/rpki-explorer/src/features/overview/OverviewPage.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, ArrowUpRight, CheckCircle2, Clock3, ShieldCheck } from "lucide-react"; -import { objectTypeRows, overviewKpis, topRepositories, validationIssues, validationSlices } from "../../test/fixtures/dashboard"; -import { - getLatestRun, - getStatsObjectTypes, - getStatsReasons, - getStatsValidation, - listRepos, - type CountsByKey, - type RepositoryRecord, - type RunRecord -} from "../../api/queryService"; -import { normalizeApiError } from "../../api/client"; -import { CopyableValue } from "../../components/common/CopyableValue"; - -const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"]; -const objectTypeLabels: Record = { - aspa: "ASPA", - certificate: "CER / RC", - crl: "CRL", - gbr: "GBR", - manifest: "MFT", - roa: "ROA", - router_certificate: "Router Cert" -}; - -export function OverviewPage() { - const latestRunQuery = useQuery({ - queryKey: ["latest-run"], - queryFn: getLatestRun - }); - const liveRun = latestRunQuery.data?.data; - const runId = liveRun?.runId ?? "latest"; - const enabled = Boolean(liveRun); - const objectTypesQuery = useQuery({ - queryKey: ["stats-object-types", runId], - queryFn: () => getStatsObjectTypes(runId), - enabled - }); - const validationQuery = useQuery({ - queryKey: ["stats-validation", runId], - queryFn: () => getStatsValidation(runId), - enabled - }); - const reasonsQuery = useQuery({ - queryKey: ["stats-reasons", runId], - queryFn: () => getStatsReasons(runId), - enabled - }); - const reposQuery = useQuery({ - queryKey: ["top-repos", runId], - queryFn: () => listRepos(runId, 8), - enabled - }); - - const error = latestRunQuery.error ? normalizeApiError(latestRunQuery.error) : null; - const kpis = liveRun ? kpisFromRun(liveRun) : overviewKpis; - const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows; - const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices; - const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100); - const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures(); - const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues; - const statusText = liveRun ? statusForRun(liveRun) : "Static fixture"; - const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected"; - - return ( -
- {error ? ( -
- Query service unavailable - {error.message}. Showing static fallback data for layout inspection. -
- ) : null} -
-
-

{liveRun ? `Latest ready run · ${liveRun.syncMode ?? "unknown"} mode` : "Latest ready run · fallback"}

-

Global RPKI validation health

-

- {liveRun - ? `Live query-service view for ${liveRun.runId}, validated at ${formatTimestamp(liveRun.validationTime)}.` - : "Static fallback showing how operators inspect validation output, repository health, and input quality."} -

-
-
- -
- {statusText} - {statusSubtext} -
-
-
- -
- {kpis.map((metric) => ( -
- {metric.label} - {metric.value} - {metric.delta} -
- ))} -
- -
-
-
-
-

Validation

-

Result distribution

-
- -
-
-
-
- {validPercent}% - usable -
-
-
- {validationRows.map((slice) => ( -
- - - {slice.value}% -
- ))} -
-
- -
-
-
-

Objects

-

Object type mix

-
- -
-
- {objectRows.map((row) => ( -
-
- {row.type} - {row.count} -
-
- -
-
- ))} -
-
- -
-
-
-

Repositories

-

Top repositories by workload

-
- -
- - - - - - - - - - - - {repoRows.map((repo) => ( - - - - - - - - ))} - -
HostTransportDurationObjectsStatus
{repo.host}{repo.transport}{repo.duration}{repo.objects} - {repo.status} -
-
- -
-
-
-

Triage

-

Recent validation issues

-
- -
-
- {issueRows.map((issue) => ( -
-
- {issue.severity} - {issue.type} -
-

{issue.reason}

- - {issue.repo} -
- ))} -
-
-
-
- ); -} - -function kpisFromRun(run: RunRecord) { - return [ - { label: "VRPs", value: formatNumber(run.counts.vrps), delta: "latest run", tone: "blue" }, - { label: "ASPAs", value: formatNumber(run.counts.aspas), delta: "latest run", tone: "cyan" }, - { label: "Objects", value: formatNumber(run.counts.objects), delta: "indexed inputs", tone: "blue" }, - { label: "Publication Points", value: formatNumber(run.counts.publicationPoints), delta: "indexed", tone: "purple" }, - { label: "Rejected Objects", value: formatNumber(run.counts.rejectedObjects), delta: "validation rejects", tone: "red" }, - { label: "Warnings", value: formatNumber(run.counts.warnings), delta: `${formatDuration(run.wallMs)} wall`, tone: "amber" } - ]; -} - -function objectRowsFromStats(stats: CountsByKey) { - const total = sumValues(stats) || 1; - return Object.entries(stats) - .sort(([, left], [, right]) => right - left) - .slice(0, 7) - .map(([type, count]) => ({ - type: objectTypeLabels[type] ?? type, - count: formatNumber(count), - percent: Math.max(1, Math.round((count / total) * 100)) - })); -} - -function validationRowsFromStats(stats: CountsByKey) { - const total = sumValues(stats) || 1; - return Object.entries(stats) - .sort(([, left], [, right]) => right - left) - .map(([label, count], index) => ({ - label: titleCase(label), - value: Math.round((count / total) * 100), - color: validationColors[index] ?? "#94a3b8" - })); -} - -function reposFromApi(repos: RepositoryRecord[]) { - return [...repos] - .sort((left, right) => right.objects - left.objects) - .slice(0, 6) - .map((repo) => ({ - host: repo.host, - id: repo.repoId, - transport: repo.transport.toUpperCase(), - duration: formatDuration(repo.syncDurationMsTotal), - objects: formatNumber(repo.objects), - status: repoStatus(repo) - })); -} - -function reposFromFixtures() { - return topRepositories.map((repo, index) => ({ - ...repo, - id: fallbackRepoId(repo, index) - })); -} - -function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) { - return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`; -} - -function issuesFromReasons(reasons: CountsByKey) { - const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left); - if (entries.length === 0) { - return [ - { - severity: "low", - type: "OK", - reason: "No validation reject reasons reported in the latest run.", - uri: "query-service://validation/reasons", - repo: "latest run" - } - ]; - } - return entries.slice(0, 4).map(([reason, count], index) => ({ - severity: index === 0 ? "high" : index === 1 ? "medium" : "low", - type: "Reject", - reason: `${formatNumber(count)} object(s): ${reason}`, - uri: `query-service://validation/reasons/${index + 1}`, - repo: "latest run" - })); -} - -function repoStatus(repo: RepositoryRecord) { - if (repo.terminalStates.failed_no_cache || repo.phases.rrdp_failed_rsync_failed) { - return "warning"; - } - if (repo.terminalStates.fallback_current_instance || repo.phases.rsync_ok) { - return "fallback"; - } - return "healthy"; -} - -function statusForRun(run: RunRecord) { - if (run.counts.rejectedObjects > 0 || run.counts.warnings > 0) { - return "Healthy with warnings"; - } - return "Healthy"; -} - -function donutGradient(rows: Array<{ value: number; color: string }>) { - let start = 0; - const parts = rows.map((row) => { - const end = Math.min(100, start + row.value); - const segment = `${row.color} ${start}% ${end}%`; - start = end; - return segment; - }); - return `conic-gradient(${parts.join(", ")})`; -} - -function sumValues(values?: CountsByKey) { - return Object.values(values ?? {}).reduce((sum, value) => sum + value, 0); -} - -function percent(value: number, total: number) { - if (total <= 0) { - return 0; - } - return Math.round((value / total) * 100); -} - -function formatNumber(value: number) { - return new Intl.NumberFormat("en-US").format(value); -} - -function formatDuration(ms: number | null) { - if (ms === null || ms === undefined) { - return "—"; - } - if (ms < 1000) { - return `${ms}ms`; - } - return `${(ms / 1000).toFixed(1)}s`; -} - -function formatTimestamp(value: string | null) { - if (!value) { - return "unknown time"; - } - return value.replace("T", " ").replace("Z", " UTC"); -} - -function titleCase(value: string) { - return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); -} diff --git a/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx b/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx deleted file mode 100644 index 87d52e0..0000000 --- a/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react"; -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { useMemo } from "react"; -import { CopyableValue } from "../../components/common/CopyableValue"; -import { CursorPager } from "../../components/common/CursorPager"; -import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging"; -import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState"; -import { - getLatestRun, - listObjectsForPublicationPoint, - listPublicationPointsForRepo, - listRepos, - type ObjectInstanceRecord, - type PublicationPointRecord, - type RepositoryRecord -} from "../../api/queryService"; -import { normalizeApiError } from "../../api/client"; - -const REPO_PAGE_SIZE = 50; -const PP_PAGE_SIZE = 50; -const OBJECT_PAGE_SIZE = 50; - -interface RepositoriesPageProps { - browserState: RepositoryBrowserState; - onBrowserStateChange: Dispatch>; - onOpenObject?: (objectInstanceId: string) => void; -} - -export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) { - const { - selectedRepoId, - selectedPpId, - repoFilter, - ppFilter, - objectFilter, - repoPage, - ppPage, - objectPage, - isRepoPanelCollapsed, - isPpPanelCollapsed - } = browserState; - const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value })); - const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value })); - const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value })); - const setRepoPage = (updater: SetStateAction) => { - onBrowserStateChange((state) => ({ - ...state, - repoPage: typeof updater === "function" ? updater(state.repoPage) : updater - })); - }; - const setPpPage = (updater: SetStateAction) => { - onBrowserStateChange((state) => ({ - ...state, - ppPage: typeof updater === "function" ? updater(state.ppPage) : updater - })); - }; - const setObjectPage = (updater: SetStateAction) => { - onBrowserStateChange((state) => ({ - ...state, - objectPage: typeof updater === "function" ? updater(state.objectPage) : updater - })); - }; - const latestRunQuery = useQuery({ - queryKey: ["repositories-latest-run"], - queryFn: getLatestRun - }); - const runId = latestRunQuery.data?.data.runId ?? "latest"; - const reposQuery = useQuery({ - queryKey: ["repositories", runId, repoPage.cursor], - queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor), - enabled: Boolean(latestRunQuery.data?.data.runId) - }); - - const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]); - const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]); - const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null; - const ppsQuery = useQuery({ - queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor], - queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor), - enabled: Boolean(selectedRepo?.repoId) - }); - - const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]); - const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]); - const selectedPp = selectedPpId - ? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null - : null; - const objectsQuery = useQuery({ - queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor], - queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor), - enabled: Boolean(selectedPp?.ppId) - }); - const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]); - const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]); - const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length; - const ppTotal = selectedRepo?.publicationPoints; - const objectTotal = selectedPp?.objects; - - const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error; - - return ( -
-
-
-

Repository browser · live query service

-

Repository / publication point / object browser

-

Browse repo trees first, then expand publication points and object rows only on demand.

-
-
- -
- {latestRunQuery.data?.data.runId ?? "Loading run"} - {repos.length ? `${repos.length} repositories loaded` : "waiting for query service"} -
-
-
- - {error ? ( -
- Repository API unavailable - {normalizeApiError(error).message} -
- ) : null} - -
-
- } - isCollapsed={isRepoPanelCollapsed} - label="Repositories" - meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)} - onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))} - toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"} - /> - {isRepoPanelCollapsed ? ( - } label="Repositories" value={repoTotal ?? repos.length} /> - ) : ( - <> - -
- {filteredRepos.map((repo) => ( -
- - -
- ))} -
- setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))} - onPrevious={() => setRepoPage(previousPage)} - previousCount={repoPage.previous.length} - rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)} - /> - - )} -
- -
- } - isCollapsed={isPpPanelCollapsed} - label="Publication Points" - meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"} - onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))} - toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"} - /> - {isPpPanelCollapsed ? ( - } label="Publication Points" value={ppTotal ?? publicationPoints.length} /> - ) : ( - <> - {!selectedRepo ? ( -
Select a repository to load its publication points.
- ) : null} - - {ppsQuery.isFetching ? : null} -
- {filteredPublicationPoints.map((pp) => ( -
- - - {pp.manifestRsyncUri ? : null} -
- ))} -
- setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))} - onPrevious={() => setPpPage(previousPage)} - pageNumber={ppPage.previous.length + 1} - previousCount={ppPage.previous.length} - rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined} - /> - - )} -
- -
- } - label="Objects" - meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"} - /> - {!selectedPp ? ( -
Select a publication point to load its objects.
- ) : null} - - {objectsQuery.isFetching ? : null} - {selectedPp && !objectsQuery.isFetching ? ( - - ) : null} - setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))} - onPrevious={() => setObjectPage(previousPage)} - pageNumber={objectPage.previous.length + 1} - previousCount={objectPage.previous.length} - rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined} - /> -
-
-
- ); -} - -function PanelTitle({ - icon, - isCollapsed, - label, - meta, - onToggleCollapse, - toggleLabel -}: { - icon: ReactNode; - isCollapsed?: boolean; - label: string; - meta: string; - onToggleCollapse?: () => void; - toggleLabel?: string; -}) { - return ( -
-
-

{label}

-

{label}

-
-
- - {icon} - {meta} - - {onToggleCollapse ? ( - - ) : null} -
-
- ); -} - -function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { - return ( -
- {icon} - {label} - {value.toLocaleString()} -
- ); -} - -function CurrentPageFilter({ - disabled, - label, - onChange, - placeholder, - value -}: { - disabled?: boolean; - label: string; - onChange: (value: string) => void; - placeholder: string; - value: string; -}) { - return ( - - ); -} - -function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) { - if (objects.length === 0) { - return
No objects returned for this publication point.
; - } - return ( - - - - - - - - - - - - - {objects.map((object) => ( - - - - - - - - - ))} - -
TypeURIHashSourceResultAction
{object.objectType}{object.sourceSection} - -
- ); -} - -function StatusPill({ state }: { state: string }) { - const normalized = state.toLowerCase(); - const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error") - ? "warning" - : normalized.includes("cache") || normalized.includes("fallback") - ? "fallback" - : "healthy"; - return {state}; -} - -function LoadingLine() { - return ( -
- - Loading selected branch... -
- ); -} - -function shortName(uri: string) { - return uri.split("/").filter(Boolean).at(-1) ?? uri; -} - -function shortHash(value: string, prefixLength: number) { - return `${value.slice(0, prefixLength)}...`; -} - -function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) { - const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1; - const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1; - const totalText = total === undefined ? "unknown" : total.toLocaleString(); - const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : ""; - return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`; -} - -function matchesNeedle(values: Array, needle: string) { - const normalized = needle.trim().toLowerCase(); - if (!normalized) { - return true; - } - return values.some((value) => String(value ?? "").toLowerCase().includes(normalized)); -} - -function filterRepositories(repos: RepositoryRecord[], filter: string) { - return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter)); -} - -function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) { - return publicationPoints.filter((pp) => matchesNeedle([ - pp.ppId, - pp.manifestRsyncUri, - pp.publicationPointRsyncUri, - pp.rsyncBaseUri, - pp.rrdpNotificationUri, - pp.source, - pp.repoSyncSource, - pp.repoTerminalState, - pp.objects, - pp.rejectedObjects - ], filter)); -} - -function filterObjects(objects: ObjectInstanceRecord[], filter: string) { - return objects.filter((object) => matchesNeedle([ - object.objectType, - object.uri, - object.sha256, - object.sourceSection, - object.result, - object.rejectReason, - object.rejected ? "rejected" : "accepted" - ], filter)); -} diff --git a/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts b/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts deleted file mode 100644 index 304a3fe..0000000 --- a/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { PageState } from "../../components/common/cursorPaging"; - -export interface RepositoryBrowserState { - selectedRepoId: string | null; - selectedPpId: string | null; - repoFilter: string; - ppFilter: string; - objectFilter: string; - repoPage: PageState; - ppPage: PageState; - objectPage: PageState; - isRepoPanelCollapsed: boolean; - isPpPanelCollapsed: boolean; -} - -export function createRepositoryBrowserState(): RepositoryBrowserState { - return { - selectedRepoId: null, - selectedPpId: null, - repoFilter: "", - ppFilter: "", - objectFilter: "", - repoPage: createEmptyPageState(), - ppPage: createEmptyPageState(), - objectPage: createEmptyPageState(), - isRepoPanelCollapsed: false, - isPpPanelCollapsed: false - }; -} - -export function createEmptyPageState(): PageState { - return { cursor: null, previous: [] }; -} diff --git a/ui/rpki-explorer/src/lib/cursor.test.ts b/ui/rpki-explorer/src/lib/cursor.test.ts new file mode 100644 index 0000000..233e96d --- /dev/null +++ b/ui/rpki-explorer/src/lib/cursor.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + INITIAL_PAGER_STATE, + advancePager, + pageNumber, + retreatPager, + type PagerState, +} from "./cursor"; + +describe("cursor pager state machine", () => { + it("starts on page 1 with a null cursor", () => { + expect(INITIAL_PAGER_STATE.cursor).toBeNull(); + expect(pageNumber(INITIAL_PAGER_STATE)).toBe(1); + }); + + it("advances and keeps a cursor stack for going back", () => { + let state: PagerState = INITIAL_PAGER_STATE; + state = advancePager(state, "c2"); + expect(state.cursor).toBe("c2"); + expect(pageNumber(state)).toBe(2); + state = advancePager(state, "c3"); + expect(state.cursor).toBe("c3"); + expect(pageNumber(state)).toBe(3); + + state = retreatPager(state); + expect(state.cursor).toBe("c2"); + expect(pageNumber(state)).toBe(2); + state = retreatPager(state); + expect(state.cursor).toBeNull(); + expect(pageNumber(state)).toBe(1); + }); + + it("ignores advance with a null cursor (no next page)", () => { + const state = advancePager(INITIAL_PAGER_STATE, null); + expect(state).toEqual(INITIAL_PAGER_STATE); + }); + + it("ignores retreat on the first page", () => { + const state = retreatPager(INITIAL_PAGER_STATE); + expect(state).toEqual(INITIAL_PAGER_STATE); + }); +}); diff --git a/ui/rpki-explorer/src/lib/cursor.ts b/ui/rpki-explorer/src/lib/cursor.ts new file mode 100644 index 0000000..f567104 --- /dev/null +++ b/ui/rpki-explorer/src/lib/cursor.ts @@ -0,0 +1,72 @@ +/** Cursor pagination state (previous-page stack) shared by all paged tables. */ +import { useCallback, useEffect, useMemo, useState } from "react"; + +export interface CursorPager { + /** Cursor to pass to the backend for the current page (null = first page). */ + cursor: string | null; + /** 1-based page number. */ + page: number; + canPrev: boolean; + goNext: (nextCursor: string | null) => void; + goPrev: () => void; + reset: () => void; +} + +export interface PagerState { + cursor: string | null; + /** Stack of cursors that produced the previous pages (page 1 = null). */ + stack: (string | null)[]; +} + +export const INITIAL_PAGER_STATE: PagerState = { cursor: null, stack: [] }; + +/** Advance to the page identified by `nextCursor`; unchanged when null. */ +export function advancePager(state: PagerState, nextCursor: string | null): PagerState { + if (!nextCursor) return state; + return { cursor: nextCursor, stack: [...state.stack, state.cursor] }; +} + +/** Go back one page; unchanged on the first page. */ +export function retreatPager(state: PagerState): PagerState { + if (state.stack.length === 0) return state; + const stack = [...state.stack]; + const cursor = stack.pop() ?? null; + return { cursor, stack }; +} + +export function pageNumber(state: PagerState): number { + return state.stack.length + 1; +} + +/** + * Cursor pager that resets whenever `resetKey` changes (run id, filters…). + */ +export function useCursorPager(resetKey: string): CursorPager { + const [state, setState] = useState(INITIAL_PAGER_STATE); + + useEffect(() => { + setState(INITIAL_PAGER_STATE); + }, [resetKey]); + + const goNext = useCallback((nextCursor: string | null) => { + setState((s) => advancePager(s, nextCursor)); + }, []); + + const goPrev = useCallback(() => { + setState((s) => retreatPager(s)); + }, []); + + const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []); + + return useMemo( + () => ({ + cursor: state.cursor, + page: pageNumber(state), + canPrev: state.stack.length > 0, + goNext, + goPrev, + reset, + }), + [state, goNext, goPrev, reset], + ); +} diff --git a/ui/rpki-explorer/src/lib/format.test.ts b/ui/rpki-explorer/src/lib/format.test.ts new file mode 100644 index 0000000..70e4009 --- /dev/null +++ b/ui/rpki-explorer/src/lib/format.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + formatBytes, + formatDurationMs, + formatInt, + formatPercent, + formatUtc, + objectTypeLabel, + parseTime, + truncateMiddle, +} from "./format"; + +describe("formatInt", () => { + it("formats with thousands separators", () => { + expect(formatInt(1234567)).toBe("1,234,567"); + }); + it("renders dash for nullish", () => { + expect(formatInt(null)).toBe("—"); + expect(formatInt(undefined)).toBe("—"); + }); +}); + +describe("formatBytes", () => { + it("keeps small values in bytes", () => { + expect(formatBytes(512)).toBe("512 B"); + }); + it("scales to KiB/MiB/GiB", () => { + expect(formatBytes(2048)).toBe("2.0 KiB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB"); + expect(formatBytes(3 * 1024 ** 3)).toBe("3.0 GiB"); + }); + it("renders dash for nullish", () => { + expect(formatBytes(null)).toBe("—"); + }); +}); + +describe("formatDurationMs", () => { + it("sub-second values", () => { + expect(formatDurationMs(640)).toBe("640 ms"); + }); + it("seconds", () => { + expect(formatDurationMs(4200)).toBe("4.2s"); + expect(formatDurationMs(42_000)).toBe("42s"); + }); + it("minutes and hours", () => { + expect(formatDurationMs(3 * 60_000 + 4_000)).toBe("3m 04s"); + expect(formatDurationMs(2 * 3_600_000 + 5 * 60_000)).toBe("2h 05m"); + }); +}); + +describe("parseTime / formatUtc", () => { + it("parses RFC3339 and renders UTC", () => { + expect(formatUtc("2026-07-17T01:46:55Z")).toBe("2026-07-17 01:46:55 UTC"); + }); + it("passes through unparseable input", () => { + expect(formatUtc("not-a-date")).toBe("not-a-date"); + expect(parseTime("not-a-date")).toBeNull(); + }); + it("renders dash for missing values", () => { + expect(formatUtc(null)).toBe("—"); + expect(formatUtc(undefined)).toBe("—"); + }); +}); + +describe("formatPercent", () => { + it("computes one decimal", () => { + expect(formatPercent(1, 3)).toBe("33.3%"); + }); + it("guards zero total", () => { + expect(formatPercent(1, 0)).toBe("—"); + }); +}); + +describe("truncateMiddle", () => { + it("keeps short values intact", () => { + expect(truncateMiddle("abc", 10)).toBe("abc"); + }); + it("truncates with an ellipsis keeping head and tail", () => { + const out = truncateMiddle("a".repeat(100), 21); + expect(out.length).toBe(21); + expect(out).toContain("…"); + expect(out.startsWith("a".repeat(10))).toBe(true); + expect(out.endsWith("a".repeat(10))).toBe(true); + }); +}); + +describe("objectTypeLabel", () => { + it("maps known types", () => { + expect(objectTypeLabel("roa")).toBe("ROA"); + expect(objectTypeLabel("mft")).toBe("Manifest"); + expect(objectTypeLabel("asa")).toBe("ASPA"); + }); + it("uppercases unknown types", () => { + expect(objectTypeLabel("xyz")).toBe("XYZ"); + }); + it("renders dash for missing", () => { + expect(objectTypeLabel(null)).toBe("—"); + }); +}); diff --git a/ui/rpki-explorer/src/lib/format.ts b/ui/rpki-explorer/src/lib/format.ts new file mode 100644 index 0000000..3eeec4b --- /dev/null +++ b/ui/rpki-explorer/src/lib/format.ts @@ -0,0 +1,109 @@ +/** Formatting helpers for numbers, bytes, durations and timestamps. */ + +export function formatInt(value: number | null | undefined): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—"; + return value.toLocaleString("en-US"); +} + +export function formatBytes(value: number | null | undefined): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—"; + if (value < 1024) return `${value} B`; + const units = ["KiB", "MiB", "GiB", "TiB"]; + let size = value; + let unit = "B"; + for (const next of units) { + if (size < 1024) break; + size /= 1024; + unit = next; + } + return `${size >= 100 ? size.toFixed(0) : size.toFixed(1)} ${unit}`; +} + +export function formatDurationMs(value: number | null | undefined): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—"; + if (value < 1000) return `${Math.round(value)} ms`; + const totalSeconds = Math.floor(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`; + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + const fraction = value / 1000; + return `${fraction >= 10 ? fraction.toFixed(0) : fraction.toFixed(1)}s`; +} + +/** Parse an ISO/RFC3339 timestamp; returns null when unparseable. */ +export function parseTime(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** "2026-07-17 01:46:55 UTC" — stable, timezone-explicit rendering. */ +export function formatUtc(value: string | null | undefined): string { + const date = parseTime(value); + if (!date) return value ? String(value) : "—"; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` + + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC` + ); +} + +export function formatRelative(value: string | null | undefined): string { + const date = parseTime(value); + if (!date) return "—"; + const deltaMs = Date.now() - date.getTime(); + const future = deltaMs < 0; + const abs = Math.abs(deltaMs); + const minutes = Math.floor(abs / 60_000); + const hours = Math.floor(abs / 3_600_000); + const days = Math.floor(abs / 86_400_000); + let text: string; + if (abs < 60_000) text = "just now"; + else if (minutes < 60) text = `${minutes}m`; + else if (hours < 24) text = `${hours}h`; + else text = `${days}d`; + if (text === "just now") return text; + return future ? `in ${text}` : `${text} ago`; +} + +export function formatPercent(part: number, total: number): string { + if (!total) return "—"; + return `${((part / total) * 100).toFixed(1)}%`; +} + +/** Truncate a long identifier for display, keeping head and tail. */ +export function truncateMiddle(value: string, max = 32): string { + if (value.length <= max) return value; + const head = Math.ceil((max - 1) / 2); + const tail = Math.floor((max - 1) / 2); + return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; +} + +/** Human label for an object type code (accepts both report-stream and projection vocabularies). */ +export function objectTypeLabel(type: string | null | undefined): string { + switch ((type ?? "").toLowerCase()) { + case "roa": + return "ROA"; + case "mft": + case "manifest": + return "Manifest"; + case "crl": + return "CRL"; + case "cer": + case "certificate": + return "Certificate"; + case "asa": + case "aspa": + return "ASPA"; + case "gbr": + return "Ghostbusters"; + case "ee": + return "EE Certificate"; + case "other": + return "Other"; + default: + return type ? type.toUpperCase() : "—"; + } +} diff --git a/ui/rpki-explorer/src/lib/projection.test.ts b/ui/rpki-explorer/src/lib/projection.test.ts new file mode 100644 index 0000000..145b191 --- /dev/null +++ b/ui/rpki-explorer/src/lib/projection.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { asArray, asNumber, asRecord, asString } from "./projection"; + +describe("projection guards", () => { + it("asRecord accepts plain objects only", () => { + expect(asRecord({ a: 1 })).toEqual({ a: 1 }); + expect(asRecord([1])).toBeNull(); + expect(asRecord("x")).toBeNull(); + expect(asRecord(null)).toBeNull(); + }); + + it("asString / asNumber narrow primitives", () => { + expect(asString("s")).toBe("s"); + expect(asString(1)).toBeNull(); + expect(asNumber(4)).toBe(4); + expect(asNumber("4")).toBeNull(); + expect(asNumber(NaN)).toBeNull(); + }); + + it("asArray defaults to empty", () => { + expect(asArray([1, 2])).toEqual([1, 2]); + expect(asArray(undefined)).toEqual([]); + expect(asArray({})).toEqual([]); + }); +}); diff --git a/ui/rpki-explorer/src/lib/projection.ts b/ui/rpki-explorer/src/lib/projection.ts new file mode 100644 index 0000000..bc81a58 --- /dev/null +++ b/ui/rpki-explorer/src/lib/projection.ts @@ -0,0 +1,18 @@ +/** Defensive accessors for loosely-typed projection payloads. */ +export function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +export function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/ui/rpki-explorer/src/lib/run.ts b/ui/rpki-explorer/src/lib/run.ts new file mode 100644 index 0000000..4df5d0f --- /dev/null +++ b/ui/rpki-explorer/src/lib/run.ts @@ -0,0 +1,48 @@ +/** + * Run-context helpers. The active run is carried in the `?run=` search param; + * "latest" (the default, no param) resolves server-side to the newest + * indexed run. + */ +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; + +export const LATEST_RUN = "latest"; + +/** The run id to use in API paths ("latest" when no explicit selection). */ +export function useRunId(): string { + const [searchParams] = useSearchParams(); + return searchParams.get("run") ?? LATEST_RUN; +} + +/** Raw `?run=` value (null when following latest). */ +export function useRunParam(): string | null { + const [searchParams] = useSearchParams(); + return searchParams.get("run"); +} + +/** + * Returns a function that sets (or clears, for "latest") the `?run=` param + * while preserving the other current search params. + */ +export function useSetRunParam(): (runId: string | null) => void { + const [searchParams, setSearchParams] = useSearchParams(); + return useCallback( + (runId: string | null) => { + const next = new URLSearchParams(searchParams); + if (runId === null || runId === LATEST_RUN) { + next.delete("run"); + } else { + next.set("run", runId); + } + setSearchParams(next, { preventScrollReset: true }); + }, + [searchParams, setSearchParams], + ); +} + +/** Append the current run param to a path that already carries a query string. */ +export function withRunParam(path: string, runId: string): string { + if (runId === LATEST_RUN) return path; + const sep = path.includes("?") ? "&" : "?"; + return `${path}${sep}run=${encodeURIComponent(runId)}`; +} diff --git a/ui/rpki-explorer/src/lib/useRun.ts b/ui/rpki-explorer/src/lib/useRun.ts new file mode 100644 index 0000000..afa6017 --- /dev/null +++ b/ui/rpki-explorer/src/lib/useRun.ts @@ -0,0 +1,15 @@ +/** Resolve the active run record for the current `?run=` context. */ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { getRun } from "../api/service"; +import type { RunRecord } from "../api/schemas"; +import { useRunId } from "./run"; + +export function useRun(): { runId: string; runQuery: UseQueryResult } { + const runId = useRunId(); + const runQuery = useQuery({ + queryKey: ["run", runId], + queryFn: () => getRun(runId), + staleTime: 60_000, + }); + return { runId, runQuery }; +} diff --git a/ui/rpki-explorer/src/main.tsx b/ui/rpki-explorer/src/main.tsx index 4b1ff6f..ccd137d 100644 --- a/ui/rpki-explorer/src/main.tsx +++ b/ui/rpki-explorer/src/main.tsx @@ -1,13 +1,17 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; import { AppProviders } from "./app/providers"; import { App } from "./app/App"; -import "./styles/globals.css"; +import "./styles/tokens.css"; +import "./styles/base.css"; +import "./styles/shell.css"; +import "./styles/components.css"; +import "./styles/pages.css"; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - +createRoot(document.getElementById("root")!).render( + - + , ); diff --git a/ui/rpki-explorer/src/pages/ApiStatusPage.tsx b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx new file mode 100644 index 0000000..a5fc3dc --- /dev/null +++ b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx @@ -0,0 +1,111 @@ +import { useQuery } from "@tanstack/react-query"; +import { getHealth, getServiceInfo } from "../api/service"; +import { CopyableValue } from "../components/CopyableValue"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { ErrorBlock } from "../components/StateBlock"; +import { useRun } from "../lib/useRun"; + +const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [ + { method: "GET", path: "/api/v1/healthz", note: "Service health + latest indexed run" }, + { method: "GET", path: "/api/v1/runs", note: "Run history (cursor paged)" }, + { method: "GET", path: "/api/v1/runs/{run}", note: "Run record ('latest' accepted)" }, + { method: "GET", path: "/api/v1/runs/{run}/repos", note: "Repositories (cursor paged)" }, + { method: "GET", path: "/api/v1/runs/{run}/repos/{id}/publication-points", note: "PPs of a repository" }, + { method: "GET", path: "/api/v1/runs/{run}/publication-points/{id}/objects", note: "Objects of a PP (filters: type,result,rejected,reason,q)" }, + { method: "GET", path: "/api/v1/runs/{run}/objects", note: "All objects (streamed; same filters)" }, + { method: "GET", path: "/api/v1/runs/{run}/objects/by-uri?uri=", note: "Exact URI lookup" }, + { method: "GET", path: "/api/v1/runs/{run}/objects/{id}/parsed", note: "Parsed projection (needs repo-bytes-db)" }, + { method: "GET", path: "/api/v1/runs/{run}/objects/{id}/validation", note: "Validation summary" }, + { method: "GET", path: "/api/v1/runs/{run}/objects/{id}/chain", note: "Chain edges" }, + { method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" }, + { method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" }, + { method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" }, + { method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" }, + { method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" }, + { method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" }, + { method: "GET", path: "/api/v1/runs/{run}/exports/{job}/download", note: "Download export tarball" }, + { method: "POST", path: "/api/v1/runs/{run}/objects/{id}/validation/explain", note: "On-demand validation explain (audit projection)" }, +]; + +export default function ApiStatusPage() { + const { runId, runQuery } = useRun(); + const infoQuery = useQuery({ queryKey: ["service-info"], queryFn: getServiceInfo }); + const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth }); + + return ( +
+ + +
+ + {infoQuery.isError ? ( + infoQuery.refetch()} title="Query service unreachable" /> + ) : ( +
+
Service
+
{infoQuery.data?.service ?? "…"}
+
API version
+
{infoQuery.data?.version ?? "…"}
+
Health
+
+ {healthQuery.isError ? ( + + ) : ( + + )} +
+
Latest indexed run
+
{healthQuery.data?.latestReadyRun ?? "—"}
+
+ )} +
+ + +
+
Active run
+
{runQuery.data?.runId ?? runId}
+
Index status
+
+
API base
+
+
+
+
+ + +
    +
  • The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.
  • +
  • /parsed, /raw and exports require --repo-bytes-db.
  • +
  • All list endpoints paginate with limit + cursor; the envelope is {"{data, page, meta}"}.
  • +
  • VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.
  • +
+
+ + +
+ + + + + + + + + {ENDPOINTS.map((endpoint) => ( + + + + + ))} + +
EndpointPurpose
+ {endpoint.method} + {endpoint.path} + {endpoint.note}
+
+
+
+ ); +} diff --git a/ui/rpki-explorer/src/pages/ExportsPage.tsx b/ui/rpki-explorer/src/pages/ExportsPage.tsx new file mode 100644 index 0000000..bfd67fe --- /dev/null +++ b/ui/rpki-explorer/src/pages/ExportsPage.tsx @@ -0,0 +1,100 @@ +import { useQuery } from "@tanstack/react-query"; +import { exportDownloadUrl, listExports } from "../api/service"; +import type { ExportJobRecord } from "../api/schemas"; +import { DataTable, type Column } from "../components/DataTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { Notice } from "../components/StateBlock"; +import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format"; +import { useRun } from "../lib/useRun"; + +/** + * Export job history for the active run. Jobs are held in the query service + * process memory, so the list resets when the service restarts. + */ +export default function ExportsPage() { + const { runId } = useRun(); + + const exportsQuery = useQuery({ + queryKey: ["exports", runId], + queryFn: () => listExports(runId, { limit: 50 }), + refetchInterval: (query) => { + const jobs = query.state.data?.items ?? []; + return jobs.some((job) => job.status === "running") ? 2000 : false; + }, + }); + + const columns: Column[] = [ + { + key: "job", + header: "Job", + render: (job) => {job.jobId.slice(0, 12)}…, + }, + { key: "scope", header: "Scope", render: (job) => {job.scope} }, + { + key: "target", + header: "Target", + render: (job) => ( + {job.repoId ?? job.ppId ?? "object set"} + ), + }, + { key: "status", header: "Status", render: (job) => }, + { + key: "objects", + header: "Objects", + numeric: true, + render: (job) => formatInt(job.objectCount), + }, + { key: "bytes", header: "Size", numeric: true, render: (job) => formatBytes(job.bytesWritten) }, + { + key: "created", + header: "Created", + render: (job) => {formatRelative(job.createdAt)}, + }, + { + key: "error", + header: "Error", + render: (job) => + job.error ? {job.error} : , + }, + { + key: "download", + header: "", + render: (job) => + job.status === "complete" ? ( + + Download + + ) : null, + }, + ]; + + return ( +
+ + + Export jobs live in the query service process memory and are cleared on + restart. Creating exports requires the service to run with --repo-bytes-db. + New exports can be started from an object, publication point or repository + page. + + + job.jobId} + loading={exportsQuery.isPending} + error={exportsQuery.isError ? exportsQuery.error : undefined} + onRetry={() => exportsQuery.refetch()} + emptyTitle="No export jobs yet" + emptyHint="Start an export from an object detail page (object set) to see it here." + caption="Export jobs" + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/NotFoundPage.tsx b/ui/rpki-explorer/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..c60b32d --- /dev/null +++ b/ui/rpki-explorer/src/pages/NotFoundPage.tsx @@ -0,0 +1,17 @@ +import { Link } from "react-router-dom"; + +export default function NotFoundPage() { + return ( +
+
+
404
+

This page does not exist.

+

+ + Back to Overview + +

+
+
+ ); +} diff --git a/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx new file mode 100644 index 0000000..a4c4dd2 --- /dev/null +++ b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx @@ -0,0 +1,393 @@ +import { useState } from "react"; +import { Link, useParams, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Download, PackageOpen, Play, RefreshCw } from "lucide-react"; +import { apiFetchBlob, saveBlob } from "../api/client"; +import { + createExport, + explainObjectValidation, + getExportJob, + getObject, + getObjectChain, + getObjectProjection, + getObjectValidation, + rawObjectUrl, +} from "../api/service"; +import type { ValidationIssue } from "../api/schemas"; +import { CopyableValue } from "../components/CopyableValue"; +import { DataTable, type Column } from "../components/DataTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { ProjectionView } from "../components/ProjectionView"; +import { StatusPill } from "../components/StatusPill"; +import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock"; +import { TabPanel, Tabs } from "../components/Tabs"; +import { WorkflowStatus } from "../components/WorkflowStatus"; +import { formatBytes, formatInt, objectTypeLabel } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string { + const last = uri?.split("/").filter(Boolean).pop(); + if (last && last.length <= 128) return last; + const ext = type === "mft" ? ".mft" : type === "crl" ? ".crl" : type === "roa" ? ".roa" : type === "asa" ? ".asa" : ".cer"; + return `${(sha256 ?? "object").slice(0, 16)}${ext}`; +} + +function IssueList({ issues }: { issues: ValidationIssue[] }) { + if (issues.length === 0) return

No issues recorded.

; + return ( +
    + {issues.map((issue, i) => ( +
  • + + {issue.reasonCode ? {issue.reasonCode} : null} + {issue.reasonText ?? "—"} + {issue.rfcRefs?.length ? ( + + [{issue.rfcRefs.join(", ")}] + + ) : null} +
  • + ))} +
+ ); +} + +function ValidationTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { + const [forceRefresh, setForceRefresh] = useState(false); + const validationQuery = useQuery({ + queryKey: ["object-validation", runId, objectInstanceId], + queryFn: () => getObjectValidation(runId, objectInstanceId), + staleTime: 5 * 60_000, + }); + const explainMutation = useMutation({ + mutationFn: () => explainObjectValidation(runId, objectInstanceId, { forceRefresh }), + }); + + if (validationQuery.isPending) return ; + if (validationQuery.isError) { + return ( + validationQuery.refetch()} + title="Failed to load validation summary" + /> + ); + } + + const validation = validationQuery.data; + const explain = explainMutation.data; + + return ( +
+
+
Final status
+
+
Audit result
+
+
Detail
+
{validation.detailSummary ?? "—"}
+
+ +
+

File validation (parsevalidate)

+

+ +

+ +
+ +
+

Chain validation (chainvalidate)

+

+ +

+ +
+ +
+

Explain validation

+
+ + +
+ {explainMutation.isError ? ( + Explain failed: {explainMutation.error.message} + ) : null} + {explain ? ( +
+ + Explain mode: {explain.explainMode ?? "audit projection"} —{" "} + {explain.authoritative + ? "authoritative revalidation." + : "not a full revalidation; derived from the run audit trail."} + +
+
Final status
+
+
Parse stage
+
+
Chain stage
+
+ {" "} + + {formatInt(explain.chainvalidate?.edgesCount)} chain edges + +
+ {explain.chainvalidate?.note ? ( + <> +
Note
+
{explain.chainvalidate.note}
+ + ) : null} +
+
+ ) : null} +
+
+ ); +} + +function ChainTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { + const chainQuery = useQuery({ + queryKey: ["object-chain", runId, objectInstanceId], + queryFn: () => getObjectChain(runId, objectInstanceId), + staleTime: 5 * 60_000, + }); + + if (chainQuery.isPending) return ; + if (chainQuery.isError) { + return chainQuery.refetch()} title="Failed to load chain" />; + } + const edges = chainQuery.data; + if (edges.length === 0) { + return ; + } + + const columns: Column<(typeof edges)[number]>[] = [ + { key: "relation", header: "Relation", render: (edge) => {edge.relation} }, + { + key: "from", + header: "From URI", + render: (edge) => , + }, + { + key: "to", + header: "To URI", + render: (edge) => , + }, + { key: "status", header: "Status", render: (edge) => }, + ]; + + return `${e.relation}:${e.fromUri}:${e.toUri}`} caption="Certificate chain edges" />; +} + +export default function ObjectDetailPage() { + const { objectInstanceId = "" } = useParams(); + const { runId } = useRun(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = searchParams.get("tab") ?? "parsed"; + const [exportJobId, setExportJobId] = useState(null); + + const objectQuery = useQuery({ + queryKey: ["object", runId, objectInstanceId], + queryFn: () => getObject(runId, objectInstanceId), + }); + + const projectionQuery = useQuery({ + queryKey: ["object-parsed", runId, objectInstanceId], + queryFn: () => getObjectProjection(runId, objectInstanceId), + enabled: tab === "parsed", + staleTime: 5 * 60_000, + }); + + const rawMutation = useMutation({ + mutationFn: async () => { + const blob = await apiFetchBlob(rawObjectUrl(runId, objectInstanceId)); + const object = objectQuery.data; + saveBlob(blob, filenameFor(object?.uri, object?.sha256, object?.objectType ?? "")); + }, + }); + + const exportMutation = useMutation({ + mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }), + onSuccess: (job) => setExportJobId(job.jobId), + }); + + const exportJobQuery = useQuery({ + queryKey: ["export-job", runId, exportJobId], + queryFn: () => getExportJob(runId, exportJobId!), + enabled: exportJobId !== null, + refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false), + }); + + if (objectQuery.isError) { + return ( +
+ + objectQuery.refetch()} title="Failed to load object" /> +
+ ); + } + if (objectQuery.isPending) { + return ( +
+ + +
+ ); + } + + const object = objectQuery.data; + + const setTab = (next: string) => { + const params = new URLSearchParams(searchParams); + params.set("tab", next); + setSearchParams(params, { preventScrollReset: true }); + }; + + return ( +
+ + + + {objectTypeLabel(object.objectType)} object + + + + } + subtitle={} + actions={ + <> + + + + } + /> + + {rawMutation.isError ? ( + + Raw download failed: {rawMutation.error.message}. The query service needs + --repo-bytes-db to serve raw bytes. + + ) : null} + {exportMutation.isError ? ( + Export failed to start: {exportMutation.error.message} + ) : null} + {exportJobQuery.data ? : null} + + +
+
Object type
+
{objectTypeLabel(object.objectType)}
+
URI
+
+
SHA-256
+
+
Instance ID
+
+
Result
+
+
Reject reason
+
{object.rejectReason ?? }
+
Detail
+
{object.detailSummary ?? }
+
Size
+
{formatBytes(object.sizeBytes)}
+
Repository
+
+ + {object.repoId} + +
+
Publication point
+
+ + {object.ppId} + +
+
+
+ + + + +
+ {projectionQuery.isPending ? ( + + ) : projectionQuery.isError ? ( + projectionQuery.refetch()} + title="Failed to load parsed projection" + /> + ) : ( + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/ui/rpki-explorer/src/pages/ObjectsPage.tsx b/ui/rpki-explorer/src/pages/ObjectsPage.tsx new file mode 100644 index 0000000..55ffa19 --- /dev/null +++ b/ui/rpki-explorer/src/pages/ObjectsPage.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { listObjects } from "../api/service"; +import { Notice } from "../components/StateBlock"; +import { + EMPTY_OBJECT_FILTERS, + ObjectsTable, + type ObjectFilterValues, +} from "../components/ObjectsTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { useRun } from "../lib/useRun"; + +function filtersFromParams(params: URLSearchParams): ObjectFilterValues { + return { + type: params.get("type") ?? "", + result: params.get("result") ?? "", + rejectedOnly: params.get("rejected") === "true", + q: params.get("q") ?? "", + }; +} + +export default function ObjectsPage() { + const { runId } = useRun(); + const [searchParams, setSearchParams] = useSearchParams(); + const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]); + + const setFilters = (next: ObjectFilterValues) => { + const params = new URLSearchParams(searchParams); + const write = (key: string, value: string) => { + if (value) params.set(key, value); + else params.delete(key); + }; + write("type", next.type); + write("result", next.result); + write("q", next.q); + if (next.rejectedOnly) params.set("rejected", "true"); + else params.delete("rejected"); + setSearchParams(params, { preventScrollReset: true }); + }; + + const hasFilters = + filters.type !== EMPTY_OBJECT_FILTERS.type || + filters.result !== "" || + filters.rejectedOnly || + filters.q !== ""; + + return ( +
+ + {!hasFilters ? ( + + This run may contain hundreds of thousands of objects. Use the filters to + narrow the scan; all filters are applied server-side. + + ) : null} + + listObjects(runId, f)} + filters={filters} + onFiltersChange={setFilters} + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/OverviewPage.tsx b/ui/rpki-explorer/src/pages/OverviewPage.tsx new file mode 100644 index 0000000..a87e0cb --- /dev/null +++ b/ui/rpki-explorer/src/pages/OverviewPage.tsx @@ -0,0 +1,381 @@ +import { useMemo } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useQueries } from "@tanstack/react-query"; +import { + Cell, + Pie, + PieChart, + ResponsiveContainer, + Bar, + BarChart, + XAxis, + YAxis, + Tooltip, +} from "recharts"; +import { Clock, GitBranch, Timer } from "lucide-react"; +import { + getStatsObjectTypes, + getStatsReasons, + getStatsValidation, + listRepos, +} from "../api/service"; +import type { RepositoryRecord } from "../api/schemas"; +import { DataTable, type Column } from "../components/DataTable"; +import { KpiCard } from "../components/KpiCard"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { ErrorBlock, LoadingBlock } from "../components/StateBlock"; +import { + formatDurationMs, + formatInt, + formatPercent, + formatRelative, + formatUtc, + objectTypeLabel, + truncateMiddle, +} from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +/** Semantic colors keyed by validation result — never assigned by index. */ +const RESULT_COLORS: Record = { + ok: "#16a34a", + valid: "#16a34a", + warnings: "#d97706", + warning: "#d97706", + error: "#dc2626", + invalid: "#dc2626", + rejected: "#dc2626", + skipped: "#94a3b8", + unknown: "#94a3b8", +}; +const FALLBACK_COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#8b5cf6", "#64748b"]; +const TYPE_PALETTE = ["#1d4ed8", "#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#0ea5e9", "#38bdf8", "#7dd3fc"]; + +function colorForResult(name: string, index: number): string { + return RESULT_COLORS[name.toLowerCase()] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length]; +} + +export default function OverviewPage() { + const { runId, runQuery } = useRun(); + const navigate = useNavigate(); + + const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({ + queries: [ + { + queryKey: ["stats", runId, "validation"], + queryFn: () => getStatsValidation(runId), + staleTime: 60_000, + }, + { + queryKey: ["stats", runId, "object-types"], + queryFn: () => getStatsObjectTypes(runId), + staleTime: 60_000, + }, + { + queryKey: ["stats", runId, "reasons"], + queryFn: () => getStatsReasons(runId), + staleTime: 60_000, + }, + { + queryKey: ["repos", runId, "top"], + queryFn: () => listRepos(runId, { limit: 8 }), + staleTime: 60_000, + }, + ], + }); + + const run = runQuery.data; + const counts = run?.counts; + + const validationData = useMemo(() => { + const map = validationQuery.data ?? {}; + return Object.entries(map) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + }, [validationQuery.data]); + + const validationTotal = useMemo( + () => validationData.reduce((sum, entry) => sum + entry.value, 0), + [validationData], + ); + + const typeData = useMemo(() => { + const map = typesQuery.data ?? {}; + return Object.entries(map) + .map(([name, value]) => ({ name: objectTypeLabel(name), value })) + .sort((a, b) => b.value - a.value) + .slice(0, 8); + }, [typesQuery.data]); + + const reasonRows = useMemo(() => { + const map = reasonsQuery.data ?? {}; + return Object.entries(map) + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 8); + }, [reasonsQuery.data]); + + const maxReason = reasonRows[0]?.count ?? 0; + + if (runQuery.isError) { + return ( +
+ + runQuery.refetch()} + title="Failed to load the latest run" + /> +
+ ); + } + + const repoColumns: Column[] = [ + { + key: "host", + header: "Repository", + render: (repo) => ( +
+
{repo.host}
+
+ {truncateMiddle(repo.uri, 56)} +
+
+ ), + }, + { + key: "transport", + header: "Transport", + render: (repo) => {repo.transport ?? "unknown"}, + }, + { key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) }, + { + key: "rejected", + header: "Rejected", + numeric: true, + render: (repo) => + repo.rejectedObjects ? ( + + {formatInt(repo.rejectedObjects)} + + ) : ( + "0" + ), + }, + { + key: "duration", + header: "Sync time", + numeric: true, + render: (repo) => formatDurationMs(repo.syncDurationMsTotal), + }, + ]; + + return ( +
+ + {run.runId} + + + {run.syncMode ? ( + + + ) : null} + + + {run.indexStatus ? : null} + + ) : undefined + } + /> + + {runQuery.isPending ? ( + + ) : ( + <> +
+ + + + + + +
+ +
+ + {validationQuery.isError ? ( + validationQuery.refetch()} /> + ) : validationQuery.isPending ? ( + + ) : validationData.length === 0 ? ( +

No validation stats recorded.

+ ) : ( + <> +
+ + + + {validationData.map((entry, index) => ( + + ))} + + [ + `${formatInt(Number(value))} (${formatPercent(Number(value), validationTotal)})`, + String(name), + ]} + /> + + +
+
+ {validationData.map((entry, index) => ( + + + {entry.name} · {formatInt(entry.value)} ({formatPercent(entry.value, validationTotal)}) + + ))} +
+ + )} +
+ + + {typesQuery.isError ? ( + typesQuery.refetch()} /> + ) : typesQuery.isPending ? ( + + ) : typeData.length === 0 ? ( +

No object type stats recorded.

+ ) : ( +
+ + + + + formatInt(Number(value))} /> + + {typeData.map((entry, index) => ( + + ))} + + + +
+ )} +
+
+ +
+ + View all + + } + flush + > + {reposQuery.isError ? ( +
+ reposQuery.refetch()} /> +
+ ) : ( + repo.repoId} + loading={reposQuery.isPending} + emptyTitle="No repositories indexed" + onRowClick={(repo) => + navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId)) + } + /> + )} +
+ + + Validation + + } + > + {reasonsQuery.isError ? ( + reasonsQuery.refetch()} /> + ) : reasonsQuery.isPending ? ( + + ) : reasonRows.length === 0 ? ( +

No rejected objects in this run.

+ ) : ( +
+ {reasonRows.map((row) => ( + + {row.reason} + {formatInt(row.count)} +
+ )} +
+
+ + )} +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx b/ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx new file mode 100644 index 0000000..ff189a8 --- /dev/null +++ b/ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + getPublicationPoint, + listPublicationPointObjects, +} from "../api/service"; +import { CopyableValue } from "../components/CopyableValue"; +import { KpiCard } from "../components/KpiCard"; +import { + EMPTY_OBJECT_FILTERS, + ObjectsTable, + type ObjectFilterValues, +} from "../components/ObjectsTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { ErrorBlock, LoadingBlock } from "../components/StateBlock"; +import { formatDurationMs, formatInt, formatUtc } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +export default function PublicationPointDetailPage() { + const { ppId = "" } = useParams(); + const { runId } = useRun(); + const [objectFilters, setObjectFilters] = useState(EMPTY_OBJECT_FILTERS); + + const ppQuery = useQuery({ + queryKey: ["pp", runId, ppId], + queryFn: () => getPublicationPoint(runId, ppId), + }); + + if (ppQuery.isError) { + return ( +
+ + ppQuery.refetch()} title="Failed to load publication point" /> +
+ ); + } + if (ppQuery.isPending) { + return ( +
+ + +
+ ); + } + + const pp = ppQuery.data; + + return ( +
+ + + {pp.manifestRsyncUri ?? pp.ppId}} + subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined} + actions={} + /> + +
+ + + + +
+ + +
+
PP ID
+
+
Repository
+
+ + {pp.repoId} + +
+
Manifest URI
+
+
PP rsync URI
+
+
rsync base
+
+
RRDP notification
+
+
Sync source
+
{pp.repoSyncSource ?? pp.source ?? "—"}
+
Sync phase
+
{pp.repoSyncPhase ?? "—"}
+
Terminal state
+
+
thisUpdate
+
{formatUtc(pp.thisUpdate)}
+
nextUpdate
+
{formatUtc(pp.nextUpdate)}
+
+
+ + + listPublicationPointObjects(runId, ppId, filters)} + filters={objectFilters} + onFiltersChange={setObjectFilters} + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx b/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx new file mode 100644 index 0000000..bf7e54f --- /dev/null +++ b/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { listPublicationPoints } from "../api/service"; +import type { PublicationPointRecord } from "../api/schemas"; +import { CursorPagerControls } from "../components/CursorPagerControls"; +import { DataTable, type Column } from "../components/DataTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { useCursorPager } from "../lib/cursor"; +import { formatDurationMs, formatInt, truncateMiddle } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +export default function PublicationPointsPage() { + const { runId } = useRun(); + const navigate = useNavigate(); + const pager = useCursorPager(runId); + const [needle, setNeedle] = useState(""); + + const ppsQuery = useQuery({ + queryKey: ["pps", runId, pager.cursor], + queryFn: () => listPublicationPoints(runId, { limit: 50, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const rows = useMemo(() => { + const items = ppsQuery.data?.items ?? []; + const q = needle.trim().toLowerCase(); + if (!q) return items; + return items.filter((pp) => + [pp.manifestRsyncUri, pp.publicationPointRsyncUri, pp.rrdpNotificationUri, pp.rsyncBaseUri, pp.ppId] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(q)), + ); + }, [ppsQuery.data, needle]); + + const columns: Column[] = [ + { + key: "manifest", + header: "Publication point", + render: (pp) => ( +
+
+ {pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 68) : pp.ppId} +
+
{pp.ppId}
+
+ ), + }, + { + key: "source", + header: "Sync source", + render: (pp) => {pp.repoSyncSource ?? pp.source ?? "—"}, + }, + { + key: "state", + header: "Terminal state", + render: (pp) => , + }, + { key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) }, + { + key: "rejected", + header: "Rejected", + numeric: true, + render: (pp) => + pp.rejectedObjects ? ( + {formatInt(pp.rejectedObjects)} + ) : ( + "0" + ), + }, + { key: "warnings", header: "Warnings", numeric: true, render: (pp) => formatInt(pp.warnings) }, + { + key: "duration", + header: "Sync time", + numeric: true, + render: (pp) => formatDurationMs(pp.repoSyncDurationMs), + }, + ]; + + return ( +
+ + setNeedle(e.target.value)} + placeholder="Filter current page (URI/id)…" + aria-label="Filter current page" + style={{ minWidth: 220, padding: "5px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-m)" }} + /> + } + flush + > + pp.ppId} + loading={ppsQuery.isPending} + error={ppsQuery.isError ? ppsQuery.error : undefined} + onRetry={() => ppsQuery.refetch()} + emptyTitle="No publication points indexed" + caption="Publication points" + onRowClick={(pp) => + navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId)) + } + /> + pager.goNext(ppsQuery.data?.nextCursor ?? null)} + itemCount={rows.length} + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/RepositoriesPage.tsx b/ui/rpki-explorer/src/pages/RepositoriesPage.tsx new file mode 100644 index 0000000..5a84324 --- /dev/null +++ b/ui/rpki-explorer/src/pages/RepositoriesPage.tsx @@ -0,0 +1,144 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { listRepos } from "../api/service"; +import type { RepositoryRecord } from "../api/schemas"; +import { CursorPagerControls } from "../components/CursorPagerControls"; +import { DataTable, type Column } from "../components/DataTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { useCursorPager } from "../lib/cursor"; +import { formatBytes, formatDurationMs, formatInt, truncateMiddle } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +export default function RepositoriesPage() { + const { runId, runQuery } = useRun(); + const navigate = useNavigate(); + const pager = useCursorPager(runId); + const [needle, setNeedle] = useState(""); + + const reposQuery = useQuery({ + queryKey: ["repos", runId, pager.cursor], + queryFn: () => listRepos(runId, { limit: 50, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const rows = useMemo(() => { + const items = reposQuery.data?.items ?? []; + const q = needle.trim().toLowerCase(); + if (!q) return items; + return items.filter( + (repo) => + repo.host.toLowerCase().includes(q) || repo.uri.toLowerCase().includes(q), + ); + }, [reposQuery.data, needle]); + + const columns: Column[] = [ + { + key: "host", + header: "Repository", + render: (repo) => ( +
+
{repo.host}
+
+ {truncateMiddle(repo.uri, 64)} +
+
+ ), + }, + { + key: "transport", + header: "Transport", + render: (repo) => {repo.transport ?? "unknown"}, + }, + { key: "pps", header: "PPs", numeric: true, render: (repo) => formatInt(repo.publicationPoints) }, + { key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) }, + { + key: "rejected", + header: "Rejected", + numeric: true, + render: (repo) => + repo.rejectedObjects ? ( + {formatInt(repo.rejectedObjects)} + ) : ( + "0" + ), + }, + { key: "bytes", header: "Downloaded", numeric: true, render: (repo) => formatBytes(repo.downloadBytes) }, + { + key: "duration", + header: "Sync time", + numeric: true, + render: (repo) => formatDurationMs(repo.syncDurationMsTotal), + }, + { + key: "terminal", + header: "Terminal states", + render: (repo) => ( + + {Object.entries(repo.terminalStates ?? {}).map(([state, count]) => ( + + ))} + + ), + }, + ]; + + return ( +
+ + + + setNeedle(e.target.value)} + placeholder="Filter current page (host/URI)…" + style={{ minWidth: 220 }} + /> +
+ } + flush + > + repo.repoId} + loading={reposQuery.isPending} + error={reposQuery.isError ? reposQuery.error : undefined} + onRetry={() => reposQuery.refetch()} + emptyTitle="No repositories indexed" + emptyHint="The query service has not indexed any run yet." + caption="Repositories" + onRowClick={(repo) => + navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId)) + } + /> + pager.goNext(reposQuery.data?.nextCursor ?? null)} + itemCount={rows.length} + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx b/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx new file mode 100644 index 0000000..81b6e11 --- /dev/null +++ b/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx @@ -0,0 +1,228 @@ +import { useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + getRepo, + getRepoStats, + listRepoObjects, + listRepoPublicationPoints, +} from "../api/service"; +import type { PublicationPointRecord } from "../api/schemas"; +import { CopyableValue } from "../components/CopyableValue"; +import { CursorPagerControls } from "../components/CursorPagerControls"; +import { DataTable, type Column } from "../components/DataTable"; +import { KpiCard } from "../components/KpiCard"; +import { + EMPTY_OBJECT_FILTERS, + ObjectsTable, + type ObjectFilterValues, +} from "../components/ObjectsTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { TabPanel, Tabs } from "../components/Tabs"; +import { ErrorBlock, LoadingBlock } from "../components/StateBlock"; +import { useCursorPager } from "../lib/cursor"; +import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) { + const navigate = useNavigate(); + const pager = useCursorPager(`${runId}:${repoId}`); + const ppsQuery = useQuery({ + queryKey: ["repo-pps", runId, repoId, pager.cursor], + queryFn: () => listRepoPublicationPoints(runId, repoId, { limit: 50, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const columns: Column[] = [ + { + key: "manifest", + header: "Manifest URI", + render: (pp) => ( + + {pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 72) : pp.ppId} + + ), + }, + { + key: "source", + header: "Sync source", + render: (pp) => {pp.repoSyncSource ?? pp.source ?? "—"}, + }, + { + key: "state", + header: "Terminal state", + render: (pp) => , + }, + { key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) }, + { + key: "rejected", + header: "Rejected", + numeric: true, + render: (pp) => + pp.rejectedObjects ? ( + {formatInt(pp.rejectedObjects)} + ) : ( + "0" + ), + }, + { + key: "duration", + header: "Sync time", + numeric: true, + render: (pp) => formatDurationMs(pp.repoSyncDurationMs), + }, + ]; + + return ( + <> + pp.ppId} + loading={ppsQuery.isPending} + error={ppsQuery.isError ? ppsQuery.error : undefined} + onRetry={() => ppsQuery.refetch()} + emptyTitle="No publication points" + caption="Publication points in this repository" + onRowClick={(pp) => + navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId)) + } + /> + pager.goNext(ppsQuery.data?.nextCursor ?? null)} + itemCount={ppsQuery.data?.items.length} + /> + + ); +} + +export default function RepositoryDetailPage() { + const { repoId = "" } = useParams(); + const { runId, runQuery } = useRun(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = searchParams.get("tab") ?? "pps"; + const [objectFilters, setObjectFilters] = useState(EMPTY_OBJECT_FILTERS); + + const repoQuery = useQuery({ + queryKey: ["repo", runId, repoId], + queryFn: () => getRepo(runId, repoId), + }); + const statsQuery = useQuery({ + queryKey: ["repo-stats", runId, repoId], + queryFn: () => getRepoStats(runId, repoId), + }); + + if (repoQuery.isError) { + return ( +
+ + repoQuery.refetch()} title="Failed to load repository" /> +
+ ); + } + if (repoQuery.isPending) { + return ( +
+ + +
+ ); + } + + const repo = repoQuery.data; + const stats = statsQuery.data; + + const setTab = (next: string) => { + const params = new URLSearchParams(searchParams); + params.set("tab", next); + setSearchParams(params, { preventScrollReset: true }); + }; + + return ( +
+ + + } + actions={} + /> + +
+ + + + +
+ + +
+
Repository ID
+
+
URI
+
+
Host
+
{repo.host}
+
Transport
+
{repo.transport ?? "unknown"}
+
Sync phases
+
+ + {Object.entries(stats?.phases ?? repo.phases ?? {}).map(([phase, count]) => ( + {phase} {formatInt(count)} + ))} + {Object.keys(stats?.phases ?? repo.phases ?? {}).length === 0 && } + +
+
Terminal states
+
+ + {Object.entries(stats?.terminalStates ?? repo.terminalStates ?? {}).map(([state, count]) => ( + + ))} + {Object.keys(stats?.terminalStates ?? repo.terminalStates ?? {}).length === 0 && } + +
+
Run validated
+
{formatUtc(runQuery.data?.validationTime)}
+
+
+ + + + + + + + listRepoObjects(runId, repoId, filters)} + filters={objectFilters} + onFiltersChange={setObjectFilters} + /> + + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/RunsPage.tsx b/ui/rpki-explorer/src/pages/RunsPage.tsx new file mode 100644 index 0000000..9801efb --- /dev/null +++ b/ui/rpki-explorer/src/pages/RunsPage.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { getRunArtifacts, listRuns } from "../api/service"; +import type { RunRecord } from "../api/schemas"; +import { CopyableValue } from "../components/CopyableValue"; +import { CursorPagerControls } from "../components/CursorPagerControls"; +import { DataTable, type Column } from "../components/DataTable"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { useCursorPager } from "../lib/cursor"; +import { formatDurationMs, formatInt, formatRelative, formatUtc } from "../lib/format"; +import { useRunParam } from "../lib/run"; + +function RunArtifacts({ runId }: { runId: string }) { + const artifactsQuery = useQuery({ + queryKey: ["run-artifacts", runId], + queryFn: () => getRunArtifacts(runId), + staleTime: 5 * 60_000, + }); + + if (artifactsQuery.isPending) return
Loading artifacts…
; + if (artifactsQuery.isError) { + return
Failed to load artifacts: {artifactsQuery.error.message}
; + } + const entries = Object.entries(artifactsQuery.data).sort(([a], [b]) => a.localeCompare(b)); + return ( +
+
    + {entries.map(([name, path]) => ( +
  • + {name} + +
  • + ))} +
+
+ ); +} + +export default function RunsPage() { + const navigate = useNavigate(); + const runParam = useRunParam(); + const pager = useCursorPager("runs"); + const [expandedRun, setExpandedRun] = useState(null); + + const runsQuery = useQuery({ + queryKey: ["runs", "page", pager.cursor], + queryFn: () => listRuns({ limit: 25, cursor: pager.cursor }), + placeholderData: (prev) => prev, + }); + + const columns: Column[] = [ + { + key: "run", + header: "Run", + render: (run) => ( +
+
{run.runId}
+
seq {run.runSeq ?? "—"}
+
+ ), + }, + { + key: "validated", + header: "Validated", + render: (run) => {formatRelative(run.validationTime)}, + }, + { key: "mode", header: "Sync mode", render: (run) => {run.syncMode ?? "—"} }, + { key: "wall", header: "Wall time", numeric: true, render: (run) => formatDurationMs(run.wallMs) }, + { key: "objects", header: "Objects", numeric: true, render: (run) => formatInt(run.counts?.objects) }, + { key: "vrps", header: "VRPs", numeric: true, render: (run) => formatInt(run.counts?.vrps) }, + { + key: "rejected", + header: "Rejected", + numeric: true, + render: (run) => + run.counts?.rejectedObjects ? ( + {formatInt(run.counts.rejectedObjects)} + ) : ( + "0" + ), + }, + { key: "index", header: "Index", render: (run) => }, + { + key: "actions", + header: "", + render: (run) => { + const active = runParam === run.runId; + return ( + e.stopPropagation()}> + + + + ); + }, + }, + ]; + + return ( +
+ + + run.runId} + loading={runsQuery.isPending} + error={runsQuery.isError ? runsQuery.error : undefined} + onRetry={() => runsQuery.refetch()} + emptyTitle="No runs indexed" + caption="Validation runs" + /> + {expandedRun ? : null} + pager.goNext(runsQuery.data?.nextCursor ?? null)} + itemCount={runsQuery.data?.items.length} + /> + +
+ ); +} diff --git a/ui/rpki-explorer/src/pages/SearchPage.tsx b/ui/rpki-explorer/src/pages/SearchPage.tsx new file mode 100644 index 0000000..687f703 --- /dev/null +++ b/ui/rpki-explorer/src/pages/SearchPage.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { FileBox, FolderTree, Server } from "lucide-react"; +import { searchRun } from "../api/service"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock"; +import { objectTypeLabel } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; + +/** Detect queries the backend cannot serve yet (VRP lookup = backlog #070). */ +function looksLikeNetworkQuery(q: string): boolean { + return ( + /^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix + /^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish + /^(AS)?\d{1,10}$/i.test(q) // ASN + ); +} + +export default function SearchPage() { + const { runId } = useRun(); + const [searchParams, setSearchParams] = useSearchParams(); + const q = searchParams.get("q") ?? ""; + const [draft, setDraft] = useState(q); + + useEffect(() => setDraft(q), [q]); + + const searchQuery = useQuery({ + queryKey: ["search", runId, q], + queryFn: () => searchRun(runId, q, 10), + enabled: q.trim().length > 0, + }); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const params = new URLSearchParams(searchParams); + if (draft.trim()) params.set("q", draft.trim()); + else params.delete("q"); + setSearchParams(params, { preventScrollReset: true }); + }; + + const result = searchQuery.data; + const totalHits = + (result?.objects.length ?? 0) + + (result?.repos.length ?? 0) + + (result?.publicationPoints.length ?? 0); + + return ( +
+ + +
+ setDraft(e.target.value)} + placeholder="rsync://… or 64-char sha256 or repository host…" + aria-label="Search query" + autoFocus + /> + +
+ + {q && looksLikeNetworkQuery(q) ? ( + + IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup), + which is not implemented yet. The results below only cover objects, + repositories and publication points. + + ) : null} + + {!q ? ( + + ) : searchQuery.isError ? ( + searchQuery.refetch()} title="Search failed" /> + ) : searchQuery.isPending ? ( + + ) : !result || totalHits === 0 ? ( + + ) : ( + <> + {result.objects.length > 0 ? ( +
+

Objects ({result.objects.length})

+ {result.objects.map((obj) => ( + + + + + {obj.uri} + sha256 {obj.sha256 ?? "—"} + + + + ))} +
+ ) : null} + + {result.repos.length > 0 ? ( +
+

Repositories ({result.repos.length})

+ {result.repos.map((repo) => ( + + + + + {repo.host} + {repo.uri} + + {repo.transport ?? "unknown"} + + ))} +
+ ) : null} + + {result.publicationPoints.length > 0 ? ( +
+

Publication points ({result.publicationPoints.length})

+ {result.publicationPoints.map((pp) => ( + + + + + {pp.manifestRsyncUri ?? pp.ppId} + {pp.ppId} + + + + ))} +
+ ) : null} + + )} + + +
    +
  • rsync:// / https:// URI — exact object match
  • +
  • 64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan
  • +
  • any other text — substring match on repository host/URI and publication point URIs
  • +
  • IP / prefix / ASN VRP lookup is planned under backend feature #070
  • +
+
+
+ ); +} diff --git a/ui/rpki-explorer/src/pages/ValidationPage.tsx b/ui/rpki-explorer/src/pages/ValidationPage.tsx new file mode 100644 index 0000000..e75dc3b --- /dev/null +++ b/ui/rpki-explorer/src/pages/ValidationPage.tsx @@ -0,0 +1,272 @@ +import { useMemo } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { + getStatsReasons, + getStatsValidation, + listIssues, + listRepos, +} from "../api/service"; +import { KpiCard } from "../components/KpiCard"; +import { PageHeader } from "../components/PageHeader"; +import { Panel } from "../components/Panel"; +import { StatusPill } from "../components/StatusPill"; +import { ErrorBlock, LoadingBlock } from "../components/StateBlock"; +import { CursorPagerControls } from "../components/CursorPagerControls"; +import { DataTable, type Column } from "../components/DataTable"; +import { CopyableValue } from "../components/CopyableValue"; +import type { ObjectInstanceRecord } from "../api/schemas"; +import { useCursorPager } from "../lib/cursor"; +import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format"; +import { withRunParam } from "../lib/run"; +import { useRun } from "../lib/useRun"; +import { useNavigate } from "react-router-dom"; + +export default function ValidationPage() { + const { runId } = useRun(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const reason = searchParams.get("reason") ?? ""; + const type = searchParams.get("type") ?? ""; + const repoId = searchParams.get("repoId") ?? ""; + + const filterKey = JSON.stringify([runId, reason, type, repoId]); + const pager = useCursorPager(filterKey); + + const [validationQuery, reasonsQuery, reposQuery] = useQueries({ + queries: [ + { + queryKey: ["stats", runId, "validation"], + queryFn: () => getStatsValidation(runId), + staleTime: 60_000, + }, + { + queryKey: ["stats", runId, "reasons"], + queryFn: () => getStatsReasons(runId), + staleTime: 60_000, + }, + { + queryKey: ["repos", runId, "filter-options"], + queryFn: () => listRepos(runId, { limit: 200 }), + staleTime: 5 * 60_000, + }, + ], + }); + + const issuesQuery = useQuery({ + queryKey: ["issues", runId, reason, type, repoId, pager.cursor], + queryFn: () => + listIssues(runId, { + limit: 50, + cursor: pager.cursor, + reason: reason || undefined, + type: type || undefined, + repoId: repoId || undefined, + }), + placeholderData: (prev) => prev, + }); + + const validationEntries = useMemo(() => { + const map = validationQuery.data ?? {}; + return Object.entries(map).sort((a, b) => b[1] - a[1]); + }, [validationQuery.data]); + const validationTotal = validationEntries.reduce((sum, [, n]) => sum + n, 0); + + const reasonRows = useMemo(() => { + const map = reasonsQuery.data ?? {}; + return Object.entries(map) + .map(([text, count]) => ({ text, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + }, [reasonsQuery.data]); + const maxReason = reasonRows[0]?.count ?? 0; + + const setParam = (key: string, value: string) => { + const params = new URLSearchParams(searchParams); + if (value) params.set(key, value); + else params.delete(key); + setSearchParams(params, { preventScrollReset: true }); + }; + + const columns: Column[] = [ + { + key: "type", + header: "Type", + render: (obj) => {objectTypeLabel(obj.objectType)}, + }, + { + key: "uri", + header: "URI", + render: (obj) => , + }, + { + key: "result", + header: "Result", + render: (obj) => , + }, + { + key: "reason", + header: "Reject reason", + render: (obj) => + obj.rejectReason ? ( + + {truncateMiddle(obj.rejectReason, 72)} + + ) : ( + + ), + }, + { + key: "source", + header: "Source", + render: (obj) => , + }, + ]; + + return ( +
+ + +
+ + {validationQuery.isError ? ( + validationQuery.refetch()} /> + ) : validationQuery.isPending ? ( + + ) : validationEntries.length === 0 ? ( +

No validation stats recorded.

+ ) : ( +
+ {validationEntries.map(([name, count]) => ( +
+ + + + + {formatInt(count)} · {formatPercent(count, validationTotal)} + +
+ ))} +
+ )} +
+ + + {reasonsQuery.isError ? ( + reasonsQuery.refetch()} /> + ) : reasonsQuery.isPending ? ( + + ) : reasonRows.length === 0 ? ( +

No rejected objects in this run.

+ ) : ( +
+ {reasonRows.map((row) => ( + + ))} +
+ )} +
+
+ + +
+ + +
+
+ + +
+
+ + setParam("reason", e.target.value)} + placeholder="substring…" + className="mono" + /> +
+ {(reason || type || repoId) && ( + + Clear filters + + )} +
+ } + flush + > + obj.objectInstanceId} + loading={issuesQuery.isPending} + error={issuesQuery.isError ? issuesQuery.error : undefined} + onRetry={() => issuesQuery.refetch()} + emptyTitle="No validation issues" + emptyHint="Nothing was rejected or errored in this run (or filters are too strict)." + caption="Validation issues" + onRowClick={(obj) => + navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId)) + } + /> + pager.goNext(issuesQuery.data?.nextCursor ?? null)} + itemCount={issuesQuery.data?.items.length} + /> + + +
+ + +
+ + ); +} diff --git a/ui/rpki-explorer/src/styles/base.css b/ui/rpki-explorer/src/styles/base.css new file mode 100644 index 0000000..9ff658e --- /dev/null +++ b/ui/rpki-explorer/src/styles/base.css @@ -0,0 +1,108 @@ +/* Reset and base element styles. */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 13.5px; + line-height: 1.45; + color: var(--text-1); + background: var(--bg-0); + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +h4, +p { + margin: 0; +} + +a { + color: var(--blue-600); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + font: inherit; + color: inherit; +} + +input, +select, +textarea { + font: inherit; + color: var(--text-1); +} + +code, +.mono { + font-family: var(--font-mono); + font-size: 0.92em; +} + +:focus-visible { + outline: 2px solid var(--blue-500); + outline-offset: 1px; + border-radius: var(--radius-s); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.text-muted { + color: var(--text-2); +} + +.text-faint { + color: var(--text-3); +} + +.text-small { + font-size: 12px; +} + +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +::selection { + background: var(--blue-100); +} diff --git a/ui/rpki-explorer/src/styles/components.css b/ui/rpki-explorer/src/styles/components.css new file mode 100644 index 0000000..7fd471d --- /dev/null +++ b/ui/rpki-explorer/src/styles/components.css @@ -0,0 +1,603 @@ +/* Shared UI components: cards, tables, pills, buttons, banners, tabs, forms. */ +.panel { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-1); + min-width: 0; +} + +.panel-header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.panel-header h2 { + font-size: 13.5px; + font-weight: 650; +} + +.panel-header .panel-subtitle { + font-size: 12px; + color: var(--text-2); +} + +.panel-header .panel-tools { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.panel-body { + padding: var(--space-4); +} + +.panel-body.flush { + padding: 0; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-m); + background: var(--bg-1); + color: var(--text-1); + font-size: 12.5px; + font-weight: 550; + cursor: pointer; + white-space: nowrap; + transition: + background var(--transition-fast), + border-color var(--transition-fast); +} + +.btn:hover:not(:disabled) { + background: var(--bg-2); + text-decoration: none; +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn.primary { + background: var(--blue-600); + border-color: var(--blue-600); + color: #fff; +} + +.btn.primary:hover:not(:disabled) { + background: var(--blue-500); +} + +.btn.danger { + color: var(--red-700); + border-color: var(--red-100); + background: var(--red-050); +} + +.btn.small { + padding: 3px 8px; + font-size: 12px; + border-radius: var(--radius-s); +} + +/* Status pill */ +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 1px 8px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + line-height: 18px; + white-space: nowrap; + border: 1px solid transparent; +} + +.pill .pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex: none; +} + +.pill.tone-green { + background: var(--green-050); + color: var(--green-700); + border-color: var(--green-100); +} + +.pill.tone-amber { + background: var(--amber-050); + color: var(--amber-700); + border-color: var(--amber-100); +} + +.pill.tone-red { + background: var(--red-050); + color: var(--red-700); + border-color: var(--red-100); +} + +.pill.tone-blue { + background: var(--blue-050); + color: var(--blue-700); + border-color: var(--blue-100); +} + +.pill.tone-slate { + background: var(--slate-100); + color: var(--text-2); + border-color: var(--slate-200); +} + +/* Data table */ +.table-wrap { + overflow-x: auto; + border-radius: 0 0 var(--radius-l) var(--radius-l); +} + +table.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12.8px; +} + +.data-table th { + text-align: left; + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-2); + padding: 8px var(--space-3); + border-bottom: 1px solid var(--border); + background: var(--bg-2); + white-space: nowrap; + position: sticky; + top: 0; + z-index: 1; +} + +.data-table td { + padding: 7px var(--space-3); + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr.clickable { + cursor: pointer; +} + +.data-table tbody tr.clickable:hover { + background: var(--blue-050); +} + +.data-table td.num, +.data-table th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.data-table .cell-main { + font-weight: 550; +} + +.table-state { + padding: var(--space-6); + text-align: center; + color: var(--text-2); +} + +/* KPI cards */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--space-3); +} + +.kpi-card { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-l); + padding: var(--space-3) var(--space-4); + box-shadow: var(--shadow-1); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + color: inherit; +} + +.kpi-card .kpi-label { + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-2); +} + +.kpi-card .kpi-value { + font-size: 22px; + font-weight: 680; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.kpi-card .kpi-sub { + font-size: 11.5px; + color: var(--text-3); +} + +.kpi-card.tone-red .kpi-value { + color: var(--red-700); +} + +.kpi-card.tone-amber .kpi-value { + color: var(--amber-700); +} + +.kpi-card.tone-blue .kpi-value { + color: var(--blue-700); +} + +a.kpi-card:hover { + text-decoration: none; + border-color: var(--blue-500); +} + +/* Banners and state blocks */ +.banner { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-m); + border: 1px solid; + font-size: 12.8px; +} + +.banner.error { + background: var(--red-050); + border-color: var(--red-100); + color: var(--red-700); +} + +.banner.warning { + background: var(--amber-050); + border-color: var(--amber-100); + color: var(--amber-700); +} + +.banner.info { + background: var(--blue-050); + border-color: var(--blue-100); + color: var(--blue-700); +} + +.banner .banner-body { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.state-block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-8) var(--space-4); + color: var(--text-2); + text-align: center; +} + +.state-block .state-title { + font-weight: 600; + color: var(--text-1); +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-top-color: var(--blue-500); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex: none; +} + +.spinner.small { + width: 13px; + height: 13px; + border-width: 1.5px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Tabs */ +.tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.tabs [role="tab"] { + padding: 8px 14px; + border: none; + background: none; + font-size: 13px; + font-weight: 550; + color: var(--text-2); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; +} + +.tabs [role="tab"]:hover { + color: var(--text-1); +} + +.tabs [role="tab"][aria-selected="true"] { + color: var(--blue-700); + border-bottom-color: var(--blue-600); +} + +/* Filter bar */ +.filter-bar { + display: flex; + align-items: flex-end; + gap: var(--space-3); + flex-wrap: wrap; +} + +.filter-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.filter-field > label { + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-2); +} + +.filter-field input[type="text"], +.filter-field input[type="search"], +.filter-field select { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-m); + background: var(--bg-1); + font-size: 12.5px; + min-width: 140px; +} + +.filter-field input.mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.filter-check { + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 6px; + font-size: 12.5px; + color: var(--text-1); + cursor: pointer; +} + +/* Pager */ +.pager { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--text-2); + flex-wrap: wrap; +} + +.pager .pager-info { + margin-right: auto; +} + +/* Definition list for metadata */ +.meta-grid { + display: grid; + grid-template-columns: minmax(140px, max-content) minmax(0, 1fr); + gap: 6px var(--space-4); + font-size: 12.8px; +} + +.meta-grid dt { + color: var(--text-2); + font-weight: 550; + white-space: nowrap; +} + +.meta-grid dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +/* Copyable value */ +.copyable { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + max-width: 100%; +} + +.copyable .copyable-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 0.94em; +} + +.copyable .copy-btn { + display: inline-flex; + align-items: center; + border: none; + background: none; + padding: 2px; + color: var(--text-3); + cursor: pointer; + border-radius: var(--radius-s); + flex: none; +} + +.copyable .copy-btn:hover { + color: var(--blue-600); + background: var(--blue-050); +} + +.copyable .copy-btn.copied { + color: var(--green-600); +} + +/* Chips */ +.chip { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 999px; + background: var(--slate-100); + border: 1px solid var(--slate-200); + font-size: 11.5px; + font-family: var(--font-mono); + white-space: nowrap; +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* JSON block */ +.json-block { + background: var(--text-1); + color: #d7e0ee; + border-radius: var(--radius-m); + padding: var(--space-3) var(--space-4); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + white-space: pre; + margin: 0; +} + +/* Skeleton loading rows */ +.skeleton-row { + height: 14px; + border-radius: var(--radius-s); + background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 50%, var(--bg-2) 75%); + background-size: 200% 100%; + animation: shimmer 1.2s infinite; + margin: 10px var(--space-3); +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* Workflow (async job) status line */ +.workflow-status { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 12.5px; + color: var(--text-2); + flex-wrap: wrap; +} + +/* Search result groups */ +.result-group h3 { + font-size: 12px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-2); + margin-bottom: var(--space-2); +} + +.result-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 8px var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-m); + background: var(--bg-1); + margin-bottom: 6px; + min-width: 0; +} + +.result-item:hover { + border-color: var(--blue-500); + text-decoration: none; +} + +.result-item .result-kind { + flex: none; + width: 88px; +} + +.result-item .result-main { + min-width: 0; + flex: 1; +} + +.result-item .result-title { + font-family: var(--font-mono); + font-size: 12.3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-item .result-sub { + font-size: 11.5px; + color: var(--text-3); +} diff --git a/ui/rpki-explorer/src/styles/globals.css b/ui/rpki-explorer/src/styles/globals.css deleted file mode 100644 index decfced..0000000 --- a/ui/rpki-explorer/src/styles/globals.css +++ /dev/null @@ -1,1707 +0,0 @@ -:root { - color: #0b1733; - background: #f7faff; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-synthesis: none; - text-rendering: optimizeLegibility; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; - overflow-x: hidden; - background: #f7faff; -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -button:focus-visible, -input:focus-visible { - outline: 3px solid rgba(11, 91, 211, 0.32); - outline-offset: 2px; -} - -.app-shell { - display: grid; - grid-template-columns: 230px minmax(0, 1fr); - min-height: 100vh; - background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%); - transition: grid-template-columns 180ms ease; -} - -.app-shell.sidebar-collapsed { - grid-template-columns: 76px minmax(0, 1fr); -} - -.sidebar { - position: sticky; - display: flex; - flex-direction: column; - top: 0; - height: 100vh; - overflow: hidden; - border-right: 1px solid #dbe4f0; - background: rgba(255, 255, 255, 0.88); - color: #10213f; - padding: 22px 14px; -} - -.brand { - display: flex; - align-items: center; - gap: 11px; - margin-bottom: 30px; - padding: 0 10px; -} - -.brand-copy { - min-width: 0; - transition: opacity 160ms ease; -} - -.brand-mark { - flex: 0 0 36px; - display: grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: 12px; - border: 2px solid #1459d9; - color: #1459d9; - font-weight: 850; -} - -.brand-title { - margin: 0; - color: #07142d; - font-size: 21px; - font-weight: 850; - letter-spacing: -0.04em; -} - -.brand-subtitle { - color: #0b5bd3; - font-size: 9px; - font-weight: 850; - letter-spacing: 0.15em; - text-transform: uppercase; -} - -.sidebar-toggle { - display: flex; - width: 100%; - align-items: center; - justify-content: flex-start; - gap: 10px; - border: 1px solid #d2deee; - border-radius: 9px; - background: #ffffff; - color: #24466f; - font-weight: 800; - margin-top: auto; - padding: 9px 12px; - transition: background 160ms ease, border-color 160ms ease, color 160ms ease; -} - -.sidebar-toggle:hover, -.sidebar-toggle:focus-visible { - border-color: #b7d2fb; - background: #eaf3ff; - color: #0759d7; -} - -.sidebar-toggle svg { - flex: 0 0 auto; -} - -.eyebrow { - margin-bottom: 8px; - color: #60718e; - font-size: 12px; - font-weight: 850; - letter-spacing: 0.15em; - text-transform: uppercase; -} - -.nav-list { - display: grid; - gap: 8px; - margin-bottom: 16px; -} - -.nav-item { - display: flex; - width: 100%; - align-items: center; - gap: 12px; - border: 0; - border-radius: 8px; - padding: 12px 13px; - background: transparent; - color: #122544; - text-align: left; - transition: background 160ms ease, color 160ms ease; -} - -.nav-item.active, -.nav-item:hover { - background: #eaf3ff; - color: #0759d7; -} - -.nav-item svg { - flex: 0 0 auto; - color: #24466f; -} - -.nav-item.active svg, -.nav-item:hover svg { - color: #0759d7; -} - -.sidebar-collapsed .sidebar { - padding-inline: 12px; -} - -.sidebar-collapsed .brand { - justify-content: center; - margin-bottom: 18px; - padding: 0; -} - -.sidebar-collapsed .brand-copy, -.sidebar-collapsed .nav-item span, -.sidebar-collapsed .sidebar-toggle span { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} - -.sidebar-collapsed .sidebar-toggle, -.sidebar-collapsed .nav-item { - justify-content: center; - padding-inline: 0; -} - -.sidebar-collapsed .sidebar-toggle { - width: 42px; - height: 42px; - margin-top: auto; - margin-inline: auto; -} - -.sidebar-collapsed .nav-item { - height: 44px; -} - -.main-panel { - min-width: 0; -} - -.topbar { - display: flex; - min-width: 0; - min-height: 76px; - align-items: center; - justify-content: space-between; - gap: 24px; - border-bottom: 1px solid #dbe4f0; - background: rgba(255, 255, 255, 0.88); - padding: 14px 28px; -} - -h1, -h2, -h3, -p { - margin-top: 0; -} - -h2 { - margin-bottom: 8px; - font-size: 24px; - letter-spacing: -0.04em; -} - -h3 { - margin-bottom: 0; - font-size: 18px; - letter-spacing: -0.02em; -} - -.run-selector { - display: flex; - align-items: center; - gap: 18px; - max-width: 100%; - min-width: 206px; - border: 1px solid #d2deee; - border-radius: 9px; - background: white; - color: #07142d; - padding: 11px 14px; -} - -.run-selector span { - font-weight: 750; -} - -.run-selector strong { - flex: 1; - font-size: 14px; -} - -.topbar-actions { - display: flex; - flex: 1; - min-width: 0; - align-items: center; - justify-content: flex-end; - gap: 14px; -} - -.search-box { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - width: min(740px, 100%); - border: 1px solid #d2deee; - border-radius: 9px; - background: white; - padding: 11px 14px; - color: #64748b; -} - -.search-box input { - width: 100%; - border: 0; - outline: 0; - background: transparent; - color: #0f172a; -} - -.ready-pill { - display: inline-flex; - align-items: center; - gap: 9px; - border: 1px solid #b7efd4; - border-radius: 9px; - background: #eefdf5; - color: #047857; - font-size: 14px; - font-weight: 800; - padding: 11px 18px; -} - -.ready-pill span { - width: 8px; - height: 8px; - border-radius: 50%; - background: #10b981; -} - -.icon-button { - display: grid; - width: 38px; - height: 38px; - place-items: center; - border: 0; - border-radius: 9px; - background: transparent; - color: #24466f; -} - -.page-stack { - display: grid; - gap: 18px; - padding: 18px; -} - -.alert-banner { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - border: 1px solid #fed7aa; - border-radius: 12px; - background: #fff7ed; - color: #9a3412; - padding: 12px 16px; -} - -.alert-banner strong { - color: #7c2d12; -} - -.hero-dashboard { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 20px; - border: 1px solid #dbe4f0; - border-radius: 14px; - background: white; - padding: 18px 20px; -} - -.compact-hero { - padding: 16px 20px; -} - -.hero-dashboard p { - max-width: 760px; - margin-bottom: 0; - color: #53657f; - line-height: 1.55; -} - -.hero-status { - display: flex; - max-width: 100%; - min-width: 230px; - align-items: center; - gap: 12px; - border: 1px solid #dbe4f0; - border-radius: 12px; - background: #f8fbff; - color: #0b5bd3; - padding: 14px; -} - -.hero-status strong, -.hero-status span { - display: block; -} - -.hero-status span { - color: #60718e; - font-size: 13px; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 16px; -} - -.metric-card { - position: relative; - display: grid; - grid-template-columns: 52px 1fr; - gap: 13px; - min-height: 110px; - align-items: center; - border: 1px solid #dbe4f0; - border-radius: 14px; - background: white; - box-shadow: 0 14px 34px rgba(16, 33, 63, 0.05); - padding: 18px; -} - -.metric-card::before { - display: grid; - width: 52px; - height: 52px; - grid-row: span 3; - place-items: center; - border-radius: 50%; - background: var(--metric-bg, #edf4ff); - color: var(--metric-fg, #0b5bd3); - content: "◉"; - font-size: 22px; -} - -.metric-card span, -.metric-card small { - display: block; - color: #53657f; - font-size: 13px; -} - -.metric-card span { - font-weight: 750; -} - -.metric-card strong { - display: block; - margin: 2px 0; - color: #07142d; - font-size: 26px; - font-weight: 850; - letter-spacing: -0.045em; -} - -.metric-card small { - color: #05825f; -} - -.tone-blue { - --metric-bg: #eaf3ff; - --metric-fg: #0b5bd3; -} - -.tone-cyan { - --metric-bg: #e7f8fb; - --metric-fg: #0891b2; -} - -.tone-purple { - --metric-bg: #f1eafe; - --metric-fg: #7c3aed; -} - -.tone-red { - --metric-bg: #fff0f3; - --metric-fg: #e11d48; -} - -.tone-amber { - --metric-bg: #fff6e9; - --metric-fg: #ea580c; -} - -.dashboard-grid { - display: grid; - grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr) minmax(0, 0.92fr); - gap: 16px; - min-width: 0; -} - -.panel, -.repository-tree, -.object-hero, -.object-meta-grid, -.tabs, -.object-browser, -.object-detail-card { - min-width: 0; - border: 1px solid #dbe4f0; - border-radius: 14px; - background: white; - box-shadow: 0 14px 34px rgba(16, 33, 63, 0.05); -} - -.panel { - overflow-x: visible; - min-width: 0; - padding: 18px; -} - -.table-panel { - overflow-x: auto; -} - -.wide-panel { - grid-column: span 2; -} - -.panel-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; -} - -.compact-heading { - align-items: center; -} - -.panel-meta { - display: inline-flex; - align-items: center; - gap: 7px; - color: #53657f; - font-size: 12px; - font-weight: 800; -} - -.panel-heading-actions { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 8px; -} - -.panel-collapse-button { - display: inline-grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid #d2deee; - border-radius: 8px; - background: white; - color: #24466f; -} - -.panel-collapse-button:hover, -.panel-collapse-button:focus-visible { - border-color: #a9c8f6; - background: #eaf3ff; - color: #0759d7; -} - -.repo-browser-grid { - display: grid; - grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr); - gap: 16px; - min-width: 0; -} - -.repo-browser-grid.repo-panel-collapsed { - grid-template-columns: 76px minmax(340px, 1fr) minmax(520px, 1.35fr); -} - -.repo-browser-grid.pp-panel-collapsed { - grid-template-columns: minmax(300px, 0.85fr) 76px minmax(520px, 1.35fr); -} - -.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed { - grid-template-columns: 76px 76px minmax(520px, 1fr); -} - -.list-panel { - min-height: 640px; -} - -.collapsible-list-panel { - min-width: 0; -} - -.repo-browser-grid.repo-panel-collapsed .repo-list-panel, -.repo-browser-grid.pp-panel-collapsed .pp-list-panel { - padding: 12px; -} - -.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading, -.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading { - align-items: center; - flex-direction: column; -} - -.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading > div:first-child, -.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading > div:first-child, -.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-meta, -.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-meta { - display: none; -} - -.collapsed-panel-summary { - display: grid; - min-height: 520px; - place-items: center; - align-content: center; - gap: 10px; - color: #24466f; - text-align: center; -} - -.collapsed-panel-summary strong { - writing-mode: vertical-rl; - color: #07142d; - font-size: 13px; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.collapsed-panel-summary span { - color: #0759d7; - font-size: 12px; - font-weight: 850; -} - -.stack-list { - display: grid; - gap: 8px; - max-height: 600px; - overflow: auto; - padding-right: 4px; -} - -.stack-row { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 8px; - width: 100%; - border: 1px solid #e3ebf5; - border-radius: 10px; - background: #ffffff; - color: #122544; - padding: 10px; - text-align: left; -} - -.stack-row-select { - display: grid; - width: 100%; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 10px; - align-items: center; - border: 0; - background: transparent; - color: inherit; - padding: 0; - text-align: left; -} - -.stack-row.active, -.stack-row:hover { - border-color: #b7d2fb; - background: #eaf3ff; -} - -.stack-row-select strong, -.stack-row-select small { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stack-row-select small { - margin-top: 4px; - color: #60718e; - font-size: 12px; -} - -.stack-row-select em { - color: #0759d7; - font-size: 12px; - font-style: normal; - font-weight: 850; -} - -.objects-live-panel { - overflow-x: auto; - min-width: 0; -} - -.uri-cell { - max-width: 330px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.table-link-button { - border: 1px solid #b7d2fb; - border-radius: 7px; - background: #eaf3ff; - color: #0759d7; - font-size: 12px; - font-weight: 850; - padding: 6px 9px; -} - -.copyable-value { - position: relative; - display: inline-flex; - max-width: 100%; - min-width: 0; - align-items: center; - gap: 5px; - vertical-align: middle; -} - -.copyable-value-text { - display: inline-block; - max-width: min(100%, 360px); - min-width: 0; - overflow: hidden; - color: inherit; - text-overflow: ellipsis; - white-space: nowrap; -} - -.inline-copyable { - width: 100%; -} - -.inline-copyable .copyable-value-text { - max-width: min(100%, 520px); -} - -.stack-row-copy { - width: 100%; - color: #60718e; - font-size: 12px; -} - -.stack-row-copy.secondary { - color: #24466f; -} - -.stack-row-copy .copyable-value-text { - max-width: 100%; -} - -.copy-icon-button { - display: inline-grid; - flex: 0 0 auto; - width: 22px; - height: 22px; - place-items: center; - border: 1px solid #d2deee; - border-radius: 7px; - background: white; - color: #0b5bd3; - padding: 0; -} - -.copy-icon-button:hover, -.copy-icon-button:focus-visible { - border-color: #a9c8f6; - background: #eaf3ff; -} - -.copy-state-text { - color: #047857; - font-size: 11px; - font-weight: 850; -} - -.copyable-tooltip { - position: fixed; - z-index: 1000; - max-width: min(760px, calc(100vw - 32px)); - transform: translate(-50%, -100%); - border: 1px solid #b7d2fb; - border-radius: 10px; - background: #07142d; - box-shadow: 0 16px 42px rgba(7, 20, 45, 0.22); - color: white; - font-size: 12px; - line-height: 1.45; - overflow-wrap: anywhere; - padding: 9px 11px; - pointer-events: none; - white-space: normal; -} - -.current-page-filter { - display: grid; - gap: 6px; - margin-bottom: 12px; -} - -.current-page-filter span { - color: #60718e; - font-size: 11px; - font-weight: 850; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.current-page-filter input { - width: 100%; - border: 1px solid #d2deee; - border-radius: 8px; - background: white; - color: #0f172a; - padding: 9px 10px; -} - -.current-page-filter input:disabled { - background: #f7faff; - color: #94a3b8; -} - -.cursor-pager { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - margin-top: 12px; - color: #60718e; - font-size: 12px; -} - -.cursor-pager span { - display: grid; - gap: 2px; - min-width: 160px; - color: #122544; - font-weight: 850; - text-align: center; -} - -.cursor-pager small { - color: #60718e; - font-size: 11px; - font-weight: 750; -} - -.cursor-pager button { - border: 1px solid #d2deee; - border-radius: 7px; - background: white; - color: #0b5bd3; - font-weight: 800; - padding: 7px 10px; -} - -.cursor-pager button:disabled, -.ghost-button:disabled, -.primary-button:disabled, -.table-link-button:disabled, -.filter-pill:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.pagination-note, -.empty-state, -.loading-line { - margin-top: 12px; - color: #60718e; - font-size: 13px; -} - -.loading-line { - display: inline-flex; - gap: 8px; - align-items: center; - margin: 0 0 12px; -} - -.loading-line svg { - animation: spin 0.9s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.panel-icon { - color: #0b5bd3; -} - -.panel-icon.success { - color: #059669; -} - -.panel-icon.warning { - color: #ea580c; -} - -.donut-wrap { - position: relative; - display: grid; - min-height: 178px; - place-items: center; -} - -.donut { - width: 166px; - height: 166px; - border-radius: 50%; - background: conic-gradient(#0ca678 0 87%, #facc15 87% 96%, #e11d48 96% 100%); - box-shadow: inset 0 0 0 24px white; -} - -.donut-center { - position: absolute; - display: grid; - place-items: center; -} - -.donut-center strong { - color: #07142d; - font-size: 30px; - letter-spacing: -0.05em; -} - -.donut-center span { - color: #53657f; - font-size: 13px; - font-weight: 750; -} - -.legend-list, -.bar-list, -.issue-list, -.check-list, -.detail-list, -.chain-flow { - display: grid; - gap: 12px; -} - -.legend-row, -.bar-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.legend-row span:first-child { - width: 10px; - height: 10px; - border-radius: 999px; -} - -.legend-row label { - flex: 1; - color: #122544; -} - -.bar-meta { - margin-bottom: 7px; - color: #53657f; - font-size: 13px; -} - -.bar-track { - overflow: hidden; - height: 10px; - border-radius: 999px; - background: #e6edf6; -} - -.bar-track span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #0b5bd3, #20b7c9); -} - -table { - width: 100%; - min-width: 620px; - border-collapse: collapse; -} - -th { - color: #60718e; - font-size: 12px; - font-weight: 800; - letter-spacing: 0.06em; - text-align: left; - text-transform: uppercase; -} - -td, -th { - border-bottom: 1px solid #e3ebf5; - padding: 13px 10px; -} - -td { - color: #263c59; - font-size: 14px; -} - -.status-badge, -.severity { - display: inline-flex; - align-items: center; - border-radius: 999px; - font-size: 12px; - font-weight: 800; - padding: 5px 9px; - text-transform: capitalize; -} - -.status-badge.healthy, -.status-badge.matched, -.status-badge.current, -.status-badge.valid { - background: #dcfce7; - color: #047857; -} - -.status-badge.warning { - background: #fff7cc; - color: #b45309; -} - -.status-badge.fallback, -.status-badge.cached { - background: #eaf3ff; - color: #0759d7; -} - -.issue-card { - min-width: 0; - max-width: 100%; - border-bottom: 1px solid #e3ebf5; - padding: 14px 0; -} - -.issue-card:last-child { - border-bottom: 0; -} - -.issue-card div { - display: flex; - min-width: 0; - max-width: 100%; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.issue-card p { - max-width: 100%; - overflow-wrap: anywhere; - margin: 9px 0; - color: #122544; -} - -.issue-card code { - display: block; - max-width: 100%; - overflow: hidden; - color: #53657f; - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.issue-card small { - display: block; - max-width: 100%; - overflow-wrap: anywhere; - margin-top: 7px; - color: #60718e; -} - -.severity.high { - background: #ffe5eb; - color: #be123c; -} - -.severity.medium { - background: #fff3dc; - color: #c2410c; -} - -.severity.low { - background: #eaf3ff; - color: #0759d7; -} - -.ghost-button, -.primary-button { - display: inline-flex; - align-items: center; - gap: 8px; - border-radius: 8px; - font-size: 13px; - font-weight: 800; - padding: 9px 12px; -} - -.ghost-button { - border: 1px solid #d2deee; - background: white; - color: #0b5bd3; -} - -.primary-button { - border: 1px solid #0b5bd3; - background: #0b5bd3; - color: white; -} - -.object-page { - padding: 0; -} - -.object-layout { - display: grid; - grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr); - min-height: calc(100vh - 76px); - gap: 0; - min-width: 0; -} - -.object-detail-only-layout { - min-height: calc(100vh - 76px); - min-width: 0; - padding: 18px; -} - -.object-detail-card-expanded { - width: min(100%, 1120px); - margin: 0 auto; -} - -.object-detail-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid #dbe4f0; - margin-bottom: 18px; - padding-bottom: 14px; -} - -.object-detail-toolbar .search-box { - flex: 1 1 420px; - max-width: 580px; - padding: 9px 12px; -} - -.object-detail-toolbar strong { - color: #24466f; - font-size: 13px; -} - -.repository-tree { - border-top: 0; - border-bottom: 0; - border-left: 0; - border-radius: 0; - box-shadow: none; - padding: 20px 14px; -} - -.tree-node { - position: relative; - margin-top: 10px; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: #263c59; - font-size: 13px; - font-weight: 750; - padding: 9px 10px; -} - -.tree-node.root { - background: #f7faff; -} - -.tree-node.nested { - margin-left: 18px; -} - -.tree-node.current { - border-color: #b7d2fb; - background: #eaf3ff; - color: #0759d7; -} - -.object-picker-list { - display: grid; - gap: 4px; - margin-top: 10px; -} - -.object-picker { - display: grid; - gap: 6px; - width: calc(100% - 18px); -} - -.object-picker-select { - display: block; - width: 100%; - border: 0; - background: transparent; - color: inherit; - padding: 0; - text-align: left; -} - -.object-picker-select strong, -.object-picker-select span { - display: block; -} - -.object-picker-select span { - overflow: hidden; - margin-top: 4px; - color: #60718e; - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.object-main { - display: contents; -} - -.object-browser { - overflow-x: auto; - overflow-y: hidden; - border-top: 0; - border-bottom: 0; - border-radius: 0; - box-shadow: none; -} - -.browser-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - border-bottom: 1px solid #dbe4f0; - padding: 18px; -} - -.browser-toolbar .search-box { - flex: 1 1 220px; - max-width: 240px; - padding: 9px 12px; -} - -.browser-toolbar .object-uri-search { - max-width: 420px; -} - -.filter-pill { - border: 1px solid #d2deee; - border-radius: 8px; - background: white; - color: #53657f; - padding: 9px 12px; -} - -.object-row-active { - background: #eef6ff; - box-shadow: inset 3px 0 0 #0b5bd3; -} - -.object-type { - color: #0b5bd3; - font-weight: 850; -} - -.object-row-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.object-detail-card { - align-self: start; - margin: 14px; - padding: 18px; -} - -.object-detail-card.object-detail-card-expanded { - margin: 0 auto; -} - -.object-hero { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: space-between; - gap: 18px; - border: 0; - border-radius: 0; - box-shadow: none; - padding: 0 0 14px; -} - -.object-hero > div:first-child { - flex: 1 1 220px; - min-width: 0; -} - -.object-hero p { - overflow-wrap: anywhere; - color: #53657f; - line-height: 1.55; -} - -.object-actions { - display: flex; - flex: 0 1 auto; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; - max-width: 100%; - min-width: 0; -} - -.object-meta-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - border-radius: 8px; - box-shadow: none; - padding: 12px; -} - -.meta-item { - min-width: 0; -} - -.meta-item span { - display: block; - margin-bottom: 5px; - color: #60718e; - font-size: 12px; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.meta-item strong { - display: block; - overflow-wrap: anywhere; - color: #07142d; - font-size: 13px; -} - -.tabs { - display: flex; - gap: 20px; - overflow-x: auto; - margin-top: 12px; - border: 0; - border-bottom: 1px solid #dbe4f0; - border-radius: 0; - box-shadow: none; - padding: 0; -} - -.tabs button { - border: 0; - border-bottom: 2px solid transparent; - background: transparent; - color: #53657f; - font-weight: 800; - padding: 12px 0; -} - -.tabs button.active { - border-color: #0b5bd3; - color: #0b5bd3; -} - -.object-content-grid { - display: grid; - grid-template-columns: 1fr; - gap: 12px; - margin-top: 12px; -} - -.prefix-list { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 16px; -} - -.prefix-list span { - border-radius: 8px; - background: #eaf3ff; - color: #0759d7; - font-size: 13px; - font-weight: 800; - padding: 8px 10px; -} - -.check-item { - display: flex; - gap: 10px; - border: 1px solid #c9f3dd; - border-radius: 10px; - background: #f0fdf4; - color: #047857; - padding: 10px; -} - -.check-item.warning-check { - border-color: #fed7aa; - background: #fff7ed; - color: #c2410c; -} - -.check-item p { - margin: 4px 0 0; - color: #53657f; - font-size: 13px; - line-height: 1.45; -} - -.chain-flow { - grid-template-columns: repeat(4, minmax(92px, 1fr)); -} - -.live-chain-flow { - grid-template-columns: 1fr; -} - -.chain-node { - position: relative; - border: 1px solid #c9f3dd; - border-radius: 10px; - background: #f0fdf4; - color: #047857; - padding: 10px; -} - -.chain-node strong, -.chain-node span { - display: block; -} - -.chain-node span { - margin-top: 5px; - color: #60718e; - font-size: 12px; - font-weight: 800; -} - -.chain-node code { - display: block; - overflow-wrap: anywhere; - margin-top: 8px; - color: #24466f; - font-size: 11px; -} - -.json-preview { - overflow: auto; - max-height: 360px; - border: 1px solid #dbe4f0; - border-radius: 10px; - background: #f8fbff; - color: #10213f; - font-size: 12px; - line-height: 1.55; - padding: 12px; - white-space: pre-wrap; -} - -.object-browser table, -.objects-live-panel table { - min-width: 760px; -} - -.explain-box { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - margin-top: 14px; -} - -.explain-box span { - color: #60718e; - font-size: 13px; -} - -.explain-result { - margin-top: 14px; -} - -.inline-actions { - justify-content: flex-start; - margin-top: 10px; -} - -.workflow-status { - margin-top: 12px; - box-shadow: none; -} - -.workflow-status-list { - display: grid; - gap: 8px; - margin: 0; - padding-left: 18px; - color: #53657f; - font-size: 13px; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} - -@media (max-width: 1380px) { - .metric-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .dashboard-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .repo-browser-grid { - grid-template-columns: 1fr; - } - - .repo-browser-grid.repo-panel-collapsed, - .repo-browser-grid.pp-panel-collapsed, - .repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed { - grid-template-columns: 1fr; - } - - .collapsed-panel-summary { - min-height: 88px; - } - - .collapsed-panel-summary strong { - writing-mode: horizontal-tb; - } - - .issue-panel { - grid-column: span 2; - } -} - -@media (max-width: 1180px) { - .dashboard-grid, - .object-layout { - grid-template-columns: minmax(0, 1fr); - } - - .wide-panel, - .issue-panel { - grid-column: auto; - } - - .repository-tree { - border-right: 0; - border-bottom: 1px solid #dbe4f0; - } -} - -@media (max-width: 900px) { - .app-shell { - grid-template-columns: 1fr; - } - - .app-shell.sidebar-collapsed { - grid-template-columns: 1fr; - } - - .sidebar { - position: static; - height: auto; - overflow: visible; - } - - .sidebar-collapsed .sidebar { - padding: 22px 14px; - } - - .sidebar-collapsed .brand { - justify-content: flex-start; - margin-bottom: 30px; - padding: 0 10px; - } - - .sidebar-collapsed .brand-copy, - .sidebar-collapsed .nav-item span, - .sidebar-collapsed .sidebar-toggle span { - position: static; - width: auto; - height: auto; - overflow: visible; - clip: auto; - white-space: normal; - } - - .sidebar-collapsed .sidebar-toggle, - .sidebar-collapsed .nav-item { - justify-content: flex-start; - padding-inline: 12px; - } - - .sidebar-collapsed .sidebar-toggle { - width: 100%; - height: auto; - margin-inline: 0; - } - - .topbar, - .hero-dashboard, - .topbar-actions { - align-items: stretch; - flex-direction: column; - } - - .page-stack { - padding: 14px; - } - - .topbar { - gap: 14px; - padding: 14px; - } - - .run-selector, - .search-box, - .ready-pill, - .hero-status { - width: 100%; - min-width: 0; - } - - .ready-pill { - justify-content: center; - } - - .object-layout { - min-height: auto; - } - - .object-detail-only-layout { - min-height: auto; - padding: 14px; - } - - .object-detail-toolbar { - align-items: stretch; - flex-direction: column; - } - - .object-detail-toolbar .search-box, - .object-detail-toolbar .filter-pill { - width: 100%; - max-width: none; - } - - .object-detail-card { - margin: 14px; - } - - .object-detail-card-expanded { - margin: 0; - } - - .metric-grid, - .object-meta-grid, - .chain-flow { - grid-template-columns: 1fr; - } -} diff --git a/ui/rpki-explorer/src/styles/pages.css b/ui/rpki-explorer/src/styles/pages.css new file mode 100644 index 0000000..ce938b6 --- /dev/null +++ b/ui/rpki-explorer/src/styles/pages.css @@ -0,0 +1,217 @@ +/* Page-specific layouts. */ + +/* Overview */ +.overview-run-strip { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + align-items: center; + font-size: 12.5px; + color: var(--text-2); +} + +.overview-run-strip .run-strip-item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.overview-charts { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + gap: var(--space-4); +} + +.overview-bottom { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: var(--space-4); +} + +@media (max-width: 1100px) { + .overview-charts, + .overview-bottom { + grid-template-columns: 1fr; + } +} + +.chart-box { + width: 100%; + height: 240px; +} + +.chart-legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + font-size: 12px; + color: var(--text-2); + padding: 0 var(--space-4) var(--space-3); +} + +.chart-legend .legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.chart-legend .legend-swatch { + width: 10px; + height: 10px; + border-radius: 3px; + flex: none; +} + +/* Reason list (overview + validation) */ +.reason-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reason-row { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 4px var(--space-3); + align-items: center; + padding: 6px 8px; + border-radius: var(--radius-m); + color: inherit; +} + +a.reason-row:hover { + background: var(--blue-050); + text-decoration: none; +} + +.reason-row .reason-text { + font-size: 12.3px; + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reason-row .reason-count { + font-variant-numeric: tabular-nums; + font-weight: 650; + font-size: 12.5px; +} + +.reason-row .reason-bar { + grid-column: 1 / -1; + height: 5px; + border-radius: 3px; + background: var(--bg-2); + overflow: hidden; +} + +.reason-row .reason-bar > span { + display: block; + height: 100%; + background: var(--amber-600); + border-radius: 3px; +} + +/* Two-column detail layout */ +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +@media (max-width: 1100px) { + .detail-grid { + grid-template-columns: 1fr; + } +} + +/* Object detail header card */ +.object-header-card .meta-grid { + grid-template-columns: minmax(110px, max-content) minmax(0, 1fr); +} + +/* Runs page */ +.run-artifacts { + padding: var(--space-3) var(--space-4); + background: var(--bg-2); + border-top: 1px solid var(--border); +} + +.run-artifacts ul { + margin: 0; + padding-left: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} + +.run-artifacts li { + display: grid; + grid-template-columns: minmax(120px, 200px) minmax(0, 1fr); + gap: var(--space-3); + font-size: 12px; +} + +/* API page */ +.endpoint-table td:first-child { + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; +} + +.endpoint-table .method { + display: inline-block; + min-width: 42px; + font-weight: 700; + color: var(--blue-700); +} + +.endpoint-table .method.post { + color: var(--amber-700); +} + +/* Search page */ +.search-hero { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.search-hero input { + flex: 1; + min-width: 0; + padding: 9px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-m); + font-family: var(--font-mono); + font-size: 13px; +} + +/* Validation page */ +.validation-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + gap: var(--space-4); +} + +@media (max-width: 1100px) { + .validation-summary { + grid-template-columns: 1fr; + } +} + +/* Not found */ +.not-found { + text-align: center; + padding: var(--space-8) 0; +} + +.not-found .not-found-code { + font-size: 40px; + font-weight: 700; + color: var(--text-3); +} diff --git a/ui/rpki-explorer/src/styles/shell.css b/ui/rpki-explorer/src/styles/shell.css new file mode 100644 index 0000000..0848c76 --- /dev/null +++ b/ui/rpki-explorer/src/styles/shell.css @@ -0,0 +1,316 @@ +/* Application shell: sidebar navigation + topbar + content area. */ +.shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: var(--topbar-height) minmax(0, 1fr); + grid-template-areas: + "brand topbar" + "nav main"; + height: 100vh; +} + +.shell.collapsed { + grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr); +} + +.shell-brand { + grid-area: brand; + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-4); + background: var(--text-1); + color: #fff; + font-weight: 650; + font-size: 14px; + letter-spacing: 0.01em; + white-space: nowrap; + overflow: hidden; +} + +.shell-brand .brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-m); + background: var(--blue-500); + color: #fff; + flex: none; +} + +.shell-brand .brand-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.shell.collapsed .brand-name { + display: none; +} + +.shell-topbar { + grid-area: topbar; + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-4); + background: var(--bg-1); + border-bottom: 1px solid var(--border); + min-width: 0; +} + +.shell-topbar .topbar-search { + flex: 1 1 320px; + max-width: 520px; + min-width: 120px; + position: relative; +} + +.shell-topbar .topbar-search input { + width: 100%; + padding: 6px 10px 6px 30px; + border: 1px solid var(--border); + border-radius: var(--radius-m); + background: var(--bg-0); + font-size: 13px; +} + +.shell-topbar .topbar-search input:focus { + background: var(--bg-1); + border-color: var(--blue-500); + outline: none; + box-shadow: 0 0 0 2px var(--blue-100); +} + +.shell-topbar .topbar-search .search-icon { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--text-3); + pointer-events: none; + display: inline-flex; +} + +.shell-topbar .topbar-run { + display: flex; + align-items: center; + gap: var(--space-2); + margin-left: auto; +} + +.shell-topbar .topbar-run label { + font-size: 12px; + color: var(--text-2); + white-space: nowrap; +} + +.shell-topbar .topbar-run select { + max-width: 240px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-m); + background: var(--bg-1); + font-family: var(--font-mono); + font-size: 12px; +} + +.shell-nav { + grid-area: nav; + background: var(--text-1); + color: #cbd5e1; + padding: var(--space-3) var(--space-2); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.shell-nav .nav-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + width: 100%; + padding: 7px 10px; + margin-bottom: var(--space-2); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: var(--radius-m); + background: transparent; + color: inherit; + cursor: pointer; +} + +.shell-nav .nav-toggle:hover { + background: rgba(255, 255, 255, 0.08); +} + +.shell-nav a { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 8px 10px; + border-radius: var(--radius-m); + color: inherit; + font-size: 13px; + white-space: nowrap; + transition: background var(--transition-fast); +} + +.shell-nav a:hover { + background: rgba(255, 255, 255, 0.08); + text-decoration: none; +} + +.shell-nav a.active { + background: var(--blue-600); + color: #fff; + font-weight: 550; +} + +.shell-nav a .nav-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.shell.collapsed .shell-nav a { + justify-content: center; +} + +.shell.collapsed .shell-nav a .nav-label { + display: none; +} + +.shell-nav .nav-footer { + margin-top: auto; + padding: var(--space-2) 10px; + font-size: 11px; + color: #7d8aa0; +} + +.shell.collapsed .nav-footer { + display: none; +} + +.shell-main { + grid-area: main; + overflow-y: auto; + padding: var(--space-5) var(--space-6); + min-width: 0; +} + +.page { + max-width: 1280px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.page-header { + display: flex; + align-items: flex-start; + gap: var(--space-3); + flex-wrap: wrap; +} + +.page-header .page-title-block { + min-width: 0; + flex: 1 1 auto; +} + +.page-header h1 { + font-size: 19px; + font-weight: 650; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.page-header .page-subtitle { + margin-top: 2px; + color: var(--text-2); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.page-header .page-actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + align-items: center; +} + +.breadcrumbs { + font-size: 12px; + color: var(--text-3); + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.breadcrumbs a { + color: var(--text-2); +} + +@media (max-width: 900px) { + .shell, + .shell.collapsed { + grid-template-columns: 1fr; + grid-template-rows: var(--topbar-height) auto auto minmax(0, 1fr); + grid-template-areas: + "brand" + "topbar" + "nav" + "main"; + } + + .shell-brand .brand-name { + display: inline; + } + + .shell-topbar { + flex-wrap: wrap; + row-gap: var(--space-2); + padding: var(--space-2) var(--space-3); + } + + .shell-topbar .topbar-search { + flex: 1 1 100%; + max-width: none; + } + + .shell-topbar .topbar-run { + margin-left: 0; + } + + .shell-topbar .topbar-run select { + max-width: 150px; + } + + .shell-nav { + flex-direction: row; + align-items: center; + overflow-x: auto; + padding: var(--space-2); + } + + .shell-nav .nav-toggle, + .shell-nav .nav-footer { + display: none; + } + + .shell-nav a { + padding: 6px 10px; + } + + .shell-nav a .nav-label, + .shell.collapsed .shell-nav a .nav-label { + display: inline; + } + + .shell-main { + padding: var(--space-4); + } +} diff --git a/ui/rpki-explorer/src/styles/tokens.css b/ui/rpki-explorer/src/styles/tokens.css new file mode 100644 index 0000000..34f5eba --- /dev/null +++ b/ui/rpki-explorer/src/styles/tokens.css @@ -0,0 +1,67 @@ +/* Design tokens for RPKI Explorer — data-dense dashboard theme. */ +:root { + --font-sans: + "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, + sans-serif; + --font-mono: + ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, + "Liberation Mono", monospace; + + --text-1: #0f172a; + --text-2: #475569; + --text-3: #8a97a8; + + --bg-0: #f4f6fa; + --bg-1: #ffffff; + --bg-2: #edf1f7; + --bg-3: #e3e9f2; + + --border: #dbe2ec; + --border-strong: #c2ccd9; + + --blue-700: #1e40af; + --blue-600: #1d4ed8; + --blue-500: #2563eb; + --blue-100: #dbeafe; + --blue-050: #eff6ff; + + --amber-700: #b45309; + --amber-600: #d97706; + --amber-100: #fef3c7; + --amber-050: #fffbeb; + + --green-700: #15803d; + --green-600: #16a34a; + --green-100: #dcfce7; + --green-050: #f0fdf4; + + --red-700: #b91c1c; + --red-600: #dc2626; + --red-100: #fee2e2; + --red-050: #fef2f2; + + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + + --radius-s: 4px; + --radius-m: 8px; + --radius-l: 12px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --shadow-1: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-2: 0 4px 12px rgba(15, 23, 42, 0.1); + + --topbar-height: 52px; + --sidebar-width: 216px; + --sidebar-width-collapsed: 56px; + + --transition-fast: 120ms ease; + --transition-med: 220ms ease; +} diff --git a/ui/rpki-explorer/src/test/fixtures/dashboard.ts b/ui/rpki-explorer/src/test/fixtures/dashboard.ts deleted file mode 100644 index ae10e10..0000000 --- a/ui/rpki-explorer/src/test/fixtures/dashboard.ts +++ /dev/null @@ -1,112 +0,0 @@ -export const overviewKpis = [ - { label: "VRPs", value: "948,216", delta: "+12,834 (1.35%)", tone: "blue" }, - { label: "ASPAs", value: "4,812", delta: "+61 (2.95%)", tone: "cyan" }, - { label: "Repositories", value: "1,265", delta: "+8 (0.64%)", tone: "blue" }, - { label: "Publication Points", value: "92,438", delta: "+842 (1.63%)", tone: "purple" }, - { label: "Rejected Objects", value: "1,274", delta: "-31 (2.38%)", tone: "red" }, - { label: "Warnings", value: "3,219", delta: "+116 (3.75%)", tone: "amber" } -]; - -export const validationSlices = [ - { label: "Valid", value: 87, color: "#2563eb" }, - { label: "Warnings", value: 9, color: "#f59e0b" }, - { label: "Rejected", value: 4, color: "#ef4444" } -]; - -export const objectTypeRows = [ - { type: "ROA", count: "741,092", percent: 64 }, - { type: "CER / RC", count: "118,437", percent: 18 }, - { type: "MFT", count: "54,221", percent: 10 }, - { type: "CRL", count: "31,804", percent: 6 }, - { type: "ASPA", count: "4,812", percent: 2 } -]; - -export const topRepositories = [ - { - host: "rpki.apnic.net", - transport: "RRDP", - duration: "12.8s", - objects: "228,192", - status: "healthy" - }, - { - host: "rrdp.arin.net", - transport: "RRDP", - duration: "18.4s", - objects: "214,776", - status: "healthy" - }, - { - host: "rpki.ripe.net", - transport: "RRDP", - duration: "22.1s", - objects: "308,427", - status: "warning" - }, - { - host: "rpki-repo.registro.br", - transport: "RSYNC", - duration: "9.7s", - objects: "19,031", - status: "fallback" - } -]; - -export const validationIssues = [ - { - severity: "high", - type: "MFT", - reason: "Manifest rejected by CRL validation", - uri: "rsync://rpki-repo.registro.br/repo/9mbS.../3kM7.mft", - repo: "rpki-repo.registro.br" - }, - { - severity: "medium", - type: "ROA", - reason: "EE certificate expired", - uri: "rsync://rpki.example.net/repository/AS64496.roa", - repo: "rpki.example.net" - }, - { - severity: "low", - type: "CRL", - reason: "Next update is close", - uri: "rsync://rpki.apnic.net/member_repository/example.crl", - repo: "rpki.apnic.net" - } -]; - -export const objectDetail = { - id: "obj_7d4b_00042", - type: "ROA", - result: "valid", - uri: "rsync://rpki.apnic.net/member_repository/A91ED7F4/AS64500-203.0.113.0-24.roa", - sha256: "7d4b9a0c7f8832d5f6a9807f4371b61cf3131b6fe0fd1a89c9e4eeb7e33b8872", - source: "fresh validated", - repo: "rpki.apnic.net", - pp: "rsync://rpki.apnic.net/member_repository/A91ED7F4/", - parsed: { - asn: "AS64500", - prefixes: ["203.0.113.0/24 maxLength 24", "2001:db8:1200::/48 maxLength 48"], - eeSubject: "CN=AS64500 ROA EE", - validity: "2026-06-15T00:00:00Z → 2026-06-22T00:00:00Z" - }, - chain: [ - { label: "APNIC TAL", state: "trust anchor" }, - { label: "APNIC Root CA", state: "valid CA" }, - { label: "Member CA A91ED7F4", state: "valid CA" }, - { label: "Manifest", state: "current" }, - { label: "ROA object", state: "valid" } - ], - manifestFiles: [ - { name: "AS64500-203.0.113.0-24.roa", hash: "7d4b9a0c…8872", state: "matched" }, - { name: "A91ED7F4.crl", hash: "91ee1db2…af09", state: "current" }, - { name: "A91ED7F4.cer", hash: "3acaa8f0…21aa", state: "valid" } - ], - validation: [ - { label: "CMS signature", status: "passed", note: "Signed attributes and EE chain are valid." }, - { label: "Resource containment", status: "passed", note: "ROA prefixes are covered by parent resources." }, - { label: "Manifest membership", status: "passed", note: "Object hash matches the current manifest entry." }, - { label: "Revocation", status: "passed", note: "EE certificate is not listed in current CRL." } - ] -}; diff --git a/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts b/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts deleted file mode 100644 index 8801823..0000000 --- a/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.setTimeout(120_000); -const screenshotRoot = "../../../../specs/develop/20260623_2/m7_copyable_values_pagination_playwright"; - -async function openRepositoryBranch(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByRole("button", { name: /Repositories/i }).click(); - const reposSection = page.locator('section[aria-label="Repositories"]'); - await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); - await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); - await reposSection.locator(".stack-row-select").first().click(); - - const ppSection = page.locator('section[aria-label="Publication points"]'); - await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); - await ppSection.locator(".stack-row-select").first().click(); - - const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); - await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 }); - return objectsPanel; -} - -test("long repository, publication point, URI, and hash values expose copy and full hover text", async ({ context, page }) => { - await context.grantPermissions(["clipboard-write"], { origin: "http://127.0.0.1:5173" }); - const objectsPanel = await openRepositoryBranch(page); - - await expect(page.getByRole("button", { name: "Copy repository URI" }).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy publication point URI" }).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy manifest URI" }).first()).toBeVisible(); - await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible(); - await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible(); - - const objectUri = objectsPanel.locator(".uri-cell .copyable-value-text").first(); - const objectUriText = await objectUri.innerText(); - await objectUri.hover(); - await expect(page.locator(".copyable-tooltip")).toContainText(objectUriText); - - await objectsPanel.getByRole("button", { name: "Copy object URI" }).first().click(); - await expect(objectsPanel.locator(".copy-state-text").first()).toHaveText("Copied"); - await objectUri.hover(); - await page.screenshot({ path: `${screenshotRoot}/repository-copyable-tooltip.png`, fullPage: true }); -}); - -test("object detail keeps copy controls without extra object list panels", async ({ page }) => { - const objectsPanel = await openRepositoryBranch(page); - await objectsPanel.getByRole("button", { name: "Open" }).first().click(); - await expect(page.getByText("Object detail · live query service")).toBeVisible({ timeout: 70_000 }); - await expect.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - - await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0); - await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible(); - - const objectHash = page.locator(".object-meta-grid").getByText(/^[a-f0-9]{64}$/).first(); - const objectHashText = await objectHash.innerText(); - await objectHash.hover(); - await expect(page.locator(".copyable-tooltip")).toContainText(objectHashText.toLowerCase()); - await page.screenshot({ path: `${screenshotRoot}/object-detail-copyable-only.png`, fullPage: true }); -}); diff --git a/ui/rpki-explorer/tests/e2e/fixtures.ts b/ui/rpki-explorer/tests/e2e/fixtures.ts new file mode 100644 index 0000000..593cb9d --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/fixtures.ts @@ -0,0 +1,321 @@ +/** + * Deterministic fixture dataset for the mocked query service. + * Object types use the report-stream vocabulary (manifest / certificate / aspa). + */ + +export const RUN_2 = { + schemaVersion: 1, + runId: "run_0002", + runSeq: 2, + validationTime: "2026-07-16T23:55:00Z", + syncMode: "delta", + wallMs: 732000, + artifactPaths: { + "report.json": "/data/runs/run_0002/report.json", + "run-summary.json": "/data/runs/run_0002/run-summary.json", + }, + counts: { + publicationPoints: 4, + objects: 1284, + rejectedObjects: 2, + warnings: 3, + trustAnchors: 5, + vrps: 987, + aspas: 12, + }, + indexStatus: "ready", +}; + +export const RUN_1 = { + ...RUN_2, + runId: "run_0001", + runSeq: 1, + validationTime: "2026-07-15T23:50:00Z", + syncMode: "snapshot", + wallMs: 1290000, + artifactPaths: { + "report.json": "/data/runs/run_0001/report.json", + "run-summary.json": "/data/runs/run_0001/run-summary.json", + }, + counts: { ...RUN_2.counts, objects: 1270, vrps: 981 }, +}; + +export const RUNS = [RUN_2, RUN_1]; + +export const REPO_A = { + repoId: "repo-a1b2c3d4e5f6", + uri: "rsync://repo-a.example/ca1/", + host: "repo-a.example", + transport: "rrdp", + publicationPoints: 2, + objects: 900, + rejectedObjects: 1, + downloadBytes: 245_000_000, + syncDurationMsTotal: 12_400, + phases: { rrdp_snapshot: 1, rrdp_delta: 1 }, + terminalStates: { fresh: 2 }, +}; + +export const REPO_B = { + repoId: "repo-b2c3d4e5f6a7", + uri: "rsync://repo-b.example/ca2/", + host: "repo-b.example", + transport: "rsync", + publicationPoints: 1, + objects: 300, + rejectedObjects: 1, + downloadBytes: 80_000_000, + syncDurationMsTotal: 45_800, + phases: { rsync: 1 }, + terminalStates: { cached: 1 }, +}; + +export const REPO_C = { + repoId: "repo-c3d4e5f6a7b8", + uri: "rsync://repo-c.example/ca3/", + host: "repo-c.example", + transport: "rrdp", + publicationPoints: 1, + objects: 84, + rejectedObjects: 0, + downloadBytes: 9_000_000, + syncDurationMsTotal: 3_100, + phases: { rrdp_delta: 1 }, + terminalStates: { fresh: 1 }, +}; + +export const REPOS = [REPO_A, REPO_B, REPO_C]; + +export const PP_1 = { + ppId: "node_1", + repoId: REPO_A.repoId, + nodeId: 1, + rsyncBaseUri: "rsync://repo-a.example/ca1/", + manifestRsyncUri: "rsync://repo-a.example/ca1/manifest.mft", + publicationPointRsyncUri: "rsync://repo-a.example/ca1/", + rrdpNotificationUri: "https://repo-a.example/notification.xml", + source: "rrdp", + repoSyncSource: "rrdp", + repoSyncPhase: "rrdp_delta", + repoSyncDurationMs: 1200, + repoSyncError: null, + repoTerminalState: "fresh", + thisUpdate: "2026-07-16T23:00:00Z", + nextUpdate: "2026-07-17T23:00:00Z", + objects: 610, + rejectedObjects: 1, + warnings: 2, +}; + +export const PP_2 = { + ...PP_1, + ppId: "node_2", + nodeId: 2, + manifestRsyncUri: "rsync://repo-a.example/ca1b/manifest.mft", + publicationPointRsyncUri: "rsync://repo-a.example/ca1b/", + objects: 290, + rejectedObjects: 0, + warnings: 1, +}; + +export const PP_3 = { + ...PP_1, + ppId: "node_3", + nodeId: 3, + repoId: REPO_B.repoId, + manifestRsyncUri: "rsync://repo-b.example/ca2/manifest.mft", + publicationPointRsyncUri: "rsync://repo-b.example/ca2/", + rrdpNotificationUri: null, + source: "rsync", + repoSyncSource: "rsync", + repoSyncPhase: "rsync", + repoTerminalState: "cached", + objects: 300, + rejectedObjects: 1, + warnings: 0, +}; + +export const PPS = [PP_1, PP_2, PP_3]; + +const SHA = (ch: string) => ch.repeat(64); + +export const OBJ_ROA = { + objectInstanceId: "obj-roa-0001", + uri: "rsync://repo-a.example/ca1/alice.roa", + sha256: SHA("a"), + objectType: "roa", + result: "ok", + detailSummary: null, + repoId: REPO_A.repoId, + ppId: PP_1.ppId, + sourceSection: "fresh", + rejected: false, + rejectReason: null, +}; + +export const OBJ_MFT = { + objectInstanceId: "obj-mft-0001", + uri: "rsync://repo-a.example/ca1/manifest.mft", + sha256: SHA("b"), + objectType: "manifest", + result: "ok", + detailSummary: null, + repoId: REPO_A.repoId, + ppId: PP_1.ppId, + sourceSection: "fresh", + rejected: false, + rejectReason: null, +}; + +export const OBJ_CER_REJECTED = { + objectInstanceId: "obj-cer-0001", + uri: "rsync://repo-a.example/ca1/stale.cer", + sha256: SHA("c"), + objectType: "certificate", + result: "error", + detailSummary: "certificate expired: notAfter 2026-01-01T00:00:00Z", + repoId: REPO_A.repoId, + ppId: PP_1.ppId, + sourceSection: "cached", + rejected: true, + rejectReason: "certificate expired: notAfter 2026-01-01T00:00:00Z", +}; + +export const OBJ_ASPA = { + objectInstanceId: "obj-asa-0001", + uri: "rsync://repo-b.example/ca2/bob.asa", + sha256: SHA("d"), + objectType: "aspa", + result: "ok", + detailSummary: null, + repoId: REPO_B.repoId, + ppId: PP_3.ppId, + sourceSection: "cached", + rejected: false, + rejectReason: null, +}; + +export const OBJ_ROA_REJECTED = { + objectInstanceId: "obj-roa-0002", + uri: "rsync://repo-b.example/ca2/broken.roa", + sha256: SHA("e"), + objectType: "roa", + result: "error", + detailSummary: "manifest stale: thisUpdate in the past", + repoId: REPO_B.repoId, + ppId: PP_3.ppId, + sourceSection: "cached", + rejected: true, + rejectReason: "manifest stale: thisUpdate in the past", +}; + +export const OBJECTS = [OBJ_ROA, OBJ_MFT, OBJ_CER_REJECTED, OBJ_ASPA, OBJ_ROA_REJECTED]; + +export const STATS_VALIDATION = { ok: 1279, error: 2, skipped: 3 }; +export const STATS_OBJECT_TYPES = { + roa: 640, + manifest: 4, + crl: 4, + certificate: 620, + aspa: 12, + gbr: 4, +}; +export const STATS_REASONS = { + "certificate expired: notAfter 2026-01-01T00:00:00Z": 1, + "manifest stale: thisUpdate in the past": 1, +}; + +export const ROA_PROJECTION = { + schemaVersion: 1, + sha256: SHA("a"), + objectType: "roa", + parseStatus: "ok", + projection: { + type: "roa", + roa: { + asId: 65001, + ipAddressFamilies: [ + { + afi: "ipv4", + addresses: [ + { prefix: "203.0.113.0/24", maxLength: 24 }, + { prefix: "198.51.100.0/25", maxLength: 25 }, + ], + }, + { + afi: "ipv6", + addresses: [{ prefix: "2001:db8::/32", maxLength: 48 }], + }, + ], + }, + }, +}; + +export const MFT_PROJECTION = { + schemaVersion: 1, + sha256: SHA("b"), + objectType: "mft", + parseStatus: "ok", + projection: { + type: "mft", + manifest: { + manifestNumberHex: "0A2F", + thisUpdate: "2026-07-16T23:00:00Z", + nextUpdate: "2026-07-17T23:00:00Z", + fileHashAlg: "sha256", + fileCount: 3, + }, + }, +}; + +export const MFT_FILES = [ + { fileName: "alice.roa", hashHex: SHA("a") }, + { fileName: "manifest.mft", hashHex: SHA("b") }, + { fileName: "stale.cer", hashHex: SHA("c") }, +]; + +export const VALIDATION_SUMMARY = { + finalStatus: "valid", + auditResult: "ok", + detailSummary: null, + parsevalidate: { status: "ok", issues: [] }, + chainvalidate: { status: "ok", issues: [] }, +}; + +export const EXPLAIN_RECORD = { + finalStatus: "valid", + auditResult: "ok", + authoritative: false, + explainMode: "audit_projection", + parsevalidate: { status: "ok", issues: [] }, + chainvalidate: { status: "ok", mode: "audit_projection", issues: [], edgesCount: 2 }, + chainEdges: [], +}; + +export const CHAIN_EDGES = [ + { + relation: "issued_by", + fromUri: "rsync://repo-a.example/ca1/alice.roa", + toUri: "rsync://repo-a.example/ca1/ca-certificate.cer", + toObjectInstanceId: "obj-cer-0002", + toSha256: SHA("f"), + status: "linked", + }, + { + relation: "listed_in", + fromUri: "rsync://repo-a.example/ca1/alice.roa", + toUri: "rsync://repo-a.example/ca1/manifest.mft", + toObjectInstanceId: "obj-mft-0001", + toSha256: SHA("b"), + status: "linked", + }, +]; + +export const HEALTH = { + status: "ok", + service: "rpki_query_service", + version: 1, + latestReadyRun: "run_0002", +}; + +export const SERVICE_INFO = { service: "rpki_query_service", version: 1 }; diff --git a/ui/rpki-explorer/tests/e2e/mockApi.ts b/ui/rpki-explorer/tests/e2e/mockApi.ts new file mode 100644 index 0000000..23e1350 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/mockApi.ts @@ -0,0 +1,340 @@ +/** + * Mocked rpki_query_service for Playwright: intercepts every /api/v1 request + * and answers from the fixture dataset. Filter/query params are honored so + * tests can assert both request construction and rendered results. + */ +import type { Page, Route } from "@playwright/test"; +import { + CHAIN_EDGES, + EXPLAIN_RECORD, + HEALTH, + MFT_FILES, + MFT_PROJECTION, + OBJECTS, + PPS, + REPOS, + ROA_PROJECTION, + RUNS, + RUN_2, + SERVICE_INFO, + STATS_OBJECT_TYPES, + STATS_REASONS, + STATS_VALIDATION, + VALIDATION_SUMMARY, +} from "./fixtures"; + +export interface MockJob { + jobId: string; + runId: string; + scope: string; + status: string; + createdAt: string; + finishedAt: string | null; + objectCount: number; + bytesWritten: number; + error: string | null; + polls: number; +} + +export interface MockApi { + jobs: Map; + seedJob: (partial?: Partial) => MockJob; +} + +function envelope(data: unknown, nextCursor: string | null = null, limit = 100) { + return { + data, + page: data === null || !Array.isArray(data) ? null : { nextCursor, limit }, + meta: { runId: "run_0002", schemaVersion: 1 }, + }; +} + +function fulfillJson(route: Route, data: unknown, nextCursor: string | null = null, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(envelope(data, nextCursor)), + }); +} + +function fulfillError(route: Route, status: number, message: string) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ error: message }), + }); +} + +/** Offset-cursor pagination honoring ?limit / ?cursor. */ +function paginate(items: T[], url: URL, defaultLimit = 50): { page: T[]; next: string | null } { + const limit = Math.max(1, Number(url.searchParams.get("limit") ?? defaultLimit)); + const offset = Number(url.searchParams.get("cursor") ?? 0); + const page = items.slice(offset, offset + limit); + const nextOffset = offset + limit; + return { page, next: nextOffset < items.length ? String(nextOffset) : null }; +} + +function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS { + let out = items; + const type = url.searchParams.get("type")?.toLowerCase(); + const result = url.searchParams.get("result")?.toLowerCase(); + const rejected = url.searchParams.get("rejected"); + const reason = url.searchParams.get("reason")?.toLowerCase(); + const q = url.searchParams.get("q")?.toLowerCase(); + if (type) { + const alias: Record = { mft: "manifest", cer: "certificate", asa: "aspa" }; + const wanted = alias[type] ?? type; + out = out.filter((o) => o.objectType.toLowerCase() === wanted); + } + if (result) out = out.filter((o) => o.result.toLowerCase() === result); + if (rejected === "true") out = out.filter((o) => o.rejected); + if (rejected === "false") out = out.filter((o) => !o.rejected); + if (reason) out = out.filter((o) => o.rejectReason?.toLowerCase().includes(reason)); + if (q) out = out.filter((o) => o.uri.toLowerCase().includes(q)); + return out; +} + +export async function installMockApi( + page: Page, + options: { failLatestRun?: boolean } = {}, +): Promise { + const jobs = new Map(); + let jobSeq = 0; + + /** Simulate async completion: a running job completes on its second read. */ + const advanceJob = (job: MockJob) => { + job.polls += 1; + if (job.polls >= 2 && job.status === "running") { + job.status = "complete"; + job.finishedAt = "2026-07-17T00:10:05Z"; + job.objectCount = 1; + job.bytesWritten = 2048; + } + }; + + const seedJob = (partial: Partial = {}): MockJob => { + jobSeq += 1; + const job: MockJob = { + jobId: `job-${String(jobSeq).padStart(4, "0")}`, + runId: "run_0002", + scope: "object_set", + status: "running", + createdAt: "2026-07-17T00:10:00Z", + finishedAt: null, + objectCount: 0, + bytesWritten: 0, + error: null, + polls: 0, + ...partial, + }; + jobs.set(job.jobId, job); + return job; + }; + + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/api\/v1\/?/, ""); + const segments = path.split("/").filter(Boolean).map(decodeURIComponent); + + // Service-level + if (segments.length === 0) return fulfillJson(route, SERVICE_INFO); + if (path === "healthz") return fulfillJson(route, HEALTH); + + if (segments[0] === "runs" || segments[0] === "latest_run") { + const isLatestAlias = segments[0] === "latest_run"; + const rawRun = isLatestAlias ? "latest" : segments[1]; + const rest = isLatestAlias ? [] : segments.slice(2); + + if (!isLatestAlias && segments.length === 1) { + const { page: items, next } = paginate(RUNS, url); + return fulfillJson(route, items, next); + } + + const run = rawRun === "latest" || rawRun === "latest_run" ? RUN_2 : RUNS.find((r) => r.runId === rawRun); + if (!run) return fulfillError(route, 404, "run not found"); + + if (isLatestAlias || rest.length === 0) { + if (options.failLatestRun) return fulfillError(route, 500, "index unavailable"); + return fulfillJson(route, run); + } + + const [section, a, b, c] = rest; + + if (section === "artifacts") return fulfillJson(route, run.artifactPaths); + if (section === "summary") { + return fulfillJson(route, { + publicationPoints: run.counts.publicationPoints, + objects: run.counts.objects, + repos: REPOS.length, + vrps: run.counts.vrps, + aspas: run.counts.aspas, + }); + } + + if (section === "repos" && !a) { + const { page: items, next } = paginate(REPOS, url); + return fulfillJson(route, items, next); + } + if (section === "repos" && a) { + const repo = REPOS.find((r) => r.repoId === a); + if (!repo) return fulfillError(route, 404, "repo not found"); + if (!b) return fulfillJson(route, repo); + if (b === "stats") { + return fulfillJson(route, { + publicationPoints: repo.publicationPoints, + objects: repo.objects, + rejectedObjects: repo.rejectedObjects, + syncDurationMsTotal: repo.syncDurationMsTotal, + phases: repo.phases, + terminalStates: repo.terminalStates, + }); + } + if (b === "publication-points") { + const { page: items, next } = paginate(PPS.filter((p) => p.repoId === a), url); + return fulfillJson(route, items, next); + } + if (b === "objects") { + const filtered = applyObjectFilters(OBJECTS.filter((o) => o.repoId === a), url); + const { page: items, next } = paginate(filtered, url); + return fulfillJson(route, items, next); + } + } + + if (section === "publication-points" && !a) { + const { page: items, next } = paginate(PPS, url); + return fulfillJson(route, items, next); + } + if (section === "publication-points" && a) { + const pp = PPS.find((p) => p.ppId === a); + if (!pp) return fulfillError(route, 404, "publication point not found"); + if (!b) return fulfillJson(route, pp); + if (b === "stats") { + return fulfillJson(route, { + objects: pp.objects, + rejectedObjects: pp.rejectedObjects, + warnings: pp.warnings, + repoSyncSource: pp.repoSyncSource, + repoSyncPhase: pp.repoSyncPhase, + repoSyncDurationMs: pp.repoSyncDurationMs, + repoTerminalState: pp.repoTerminalState, + }); + } + if (b === "objects") { + const filtered = applyObjectFilters(OBJECTS.filter((o) => o.ppId === a), url); + const { page: items, next } = paginate(filtered, url); + return fulfillJson(route, items, next); + } + } + + if (section === "objects" && a === "by-uri") { + const uri = url.searchParams.get("uri"); + const obj = OBJECTS.find((o) => o.uri === uri); + if (!obj) return fulfillError(route, 404, "object not found"); + return fulfillJson(route, obj); + } + if (section === "objects" && !a) { + const filtered = applyObjectFilters(OBJECTS, url); + const { page: items, next } = paginate(filtered, url); + return fulfillJson(route, items, next); + } + if (section === "objects" && a) { + const obj = OBJECTS.find((o) => o.objectInstanceId === a); + if (!obj) return fulfillError(route, 404, "object not found"); + if (!b) return fulfillJson(route, obj); + if (b === "parsed" && !c) { + if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION); + if (obj.objectType === "manifest") return fulfillJson(route, MFT_PROJECTION); + return fulfillJson(route, null); + } + if (b === "parsed" && c === "manifest-files") { + const { page: items, next } = paginate(MFT_FILES, url, 100); + return fulfillJson(route, items, next); + } + if (b === "parsed" && c === "revoked-certs") { + return fulfillJson(route, [], null); + } + if (b === "validation" && !c) return fulfillJson(route, VALIDATION_SUMMARY); + if (b === "validation" && c === "explain" && request.method() === "POST") { + return fulfillJson(route, EXPLAIN_RECORD); + } + if (b === "chain") return fulfillJson(route, CHAIN_EDGES); + if (b === "raw") { + return route.fulfill({ + status: 200, + contentType: "application/octet-stream", + body: Buffer.from([0x30, 0x82, 0x01, 0x01, 0x02, 0x01, 0x05]), + }); + } + } + + if (section === "issues") { + let items = OBJECTS.filter((o) => o.rejected || o.result === "error"); + const reason = url.searchParams.get("reason")?.toLowerCase(); + const type = url.searchParams.get("type")?.toLowerCase(); + const repoId = url.searchParams.get("repoId"); + if (reason) items = items.filter((o) => o.rejectReason?.toLowerCase().includes(reason)); + if (type) items = items.filter((o) => o.objectType.toLowerCase() === type); + if (repoId) items = items.filter((o) => o.repoId === repoId); + const { page: pageItems, next } = paginate(items, url); + return fulfillJson(route, pageItems, next); + } + + if (section === "search") { + const q = (url.searchParams.get("q") ?? "").trim(); + if (!q) return fulfillError(route, 400, "q query parameter is required"); + const lower = q.toLowerCase(); + const byUri = OBJECTS.find((o) => o.uri === q); + const byHash = OBJECTS.find((o) => o.sha256 === lower); + const objects = byUri ? [byUri] : byHash ? [byHash] : []; + const repos = REPOS.filter( + (r) => r.host.toLowerCase().includes(lower) || r.uri.toLowerCase().includes(lower), + ); + const pps = PPS.filter((p) => + [p.manifestRsyncUri, p.publicationPointRsyncUri, p.rrdpNotificationUri] + .filter(Boolean) + .some((u) => u!.toLowerCase().includes(lower)), + ); + return fulfillJson(route, { objects, repos, publicationPoints: pps }); + } + + if (section === "stats") { + if (a === "validation") return fulfillJson(route, STATS_VALIDATION); + if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES); + if (a === "reasons") return fulfillJson(route, STATS_REASONS); + if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 }); + return fulfillJson(route, {}); + } + + if (section === "exports" && !a) { + if (request.method() === "POST") { + const job = seedJob({ objectCount: 0 }); + return fulfillJson(route, job); + } + const list = [...jobs.values()].sort((x, y) => y.createdAt.localeCompare(x.createdAt)); + list.forEach(advanceJob); + return fulfillJson(route, list, null); + } + if (section === "exports" && a) { + const job = jobs.get(a); + if (!job) return fulfillError(route, 404, "export job not found"); + if (b === "download") { + if (job.status !== "complete") return fulfillError(route, 409, "export not complete"); + return route.fulfill({ + status: 200, + contentType: "application/x-tar", + body: Buffer.from("mock-tar-bytes"), + }); + } + advanceJob(job); + return fulfillJson(route, job); + } + + return fulfillError(route, 404, `no mock for ${url.pathname}`); + } + + return fulfillError(route, 404, `no mock for ${url.pathname}`); + }); + + return { jobs, seedJob }; +} diff --git a/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts b/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts deleted file mode 100644 index 4689276..0000000 --- a/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.setTimeout(120_000); -const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; - -async function openFirstRepositoryObject(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByRole("button", { name: /Repositories/i }).click(); - const reposSection = page.locator('section[aria-label="Repositories"]'); - await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); - await reposSection.locator(".stack-row-select").first().click(); - await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click(); - const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first(); - await expect(row).toBeVisible({ timeout: 70_000 }); - const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim(); - await row.getByRole("button", { name: "Open" }).click(); - await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 }); - return expectedUri; -} - -test("renders live object detail and lazy tab requests", async ({ page }) => { - const apiRequests: string[] = []; - const consoleErrors: string[] = []; - - page.on("request", (request) => { - const url = new URL(request.url()); - if (url.pathname.startsWith("/api/v1")) { - apiRequests.push(`${url.pathname}${url.search}`); - } - }); - page.on("console", (message) => { - if (message.type() === "error") { - consoleErrors.push(message.text()); - } - }); - - await openFirstRepositoryObject(page); - await expect(page.getByText("Object detail · live query service")).toBeVisible(); - await expect(page.getByText("File and chain checks")).toBeVisible(); - const objectDetail = page.getByRole("complementary", { name: "Live object detail" }); - await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible(); - await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0); - await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible(); - expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false); - - await page.getByRole("tab", { name: "Parsed" }).click(); - await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true); - - await page.getByRole("tab", { name: "Chain" }).click(); - await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain"); - expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true); - - await page.getByRole("tab", { name: "Validation" }).click(); - await page.getByRole("button", { name: "Explain validation" }).click(); - await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true); - - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true }); - expect(consoleErrors).toEqual([]); -}); diff --git a/ui/rpki-explorer/tests/e2e/object-detail.spec.ts b/ui/rpki-explorer/tests/e2e/object-detail.spec.ts new file mode 100644 index 0000000..54af917 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/object-detail.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; + +test.beforeEach(async ({ page }) => { + await installMockApi(page); +}); + +test("object detail renders header metadata and parsed ROA projection", async ({ page }) => { + await page.goto("/objects/obj-roa-0001"); + + await expect(page.getByText("ROA object")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa").first()).toBeVisible(); + + // Parsed tab is the default and renders structured ROA content + await expect(page.getByText("AS ID")).toBeVisible(); + await expect(page.getByText("65001")).toBeVisible(); + await expect(page.getByText("203.0.113.0/24")).toBeVisible(); + await expect(page.getByText("2001:db8::/32")).toBeVisible(); +}); + +test("tabs lazy-load: chain and validation requests fire only on activation", async ({ page }) => { + const requests: string[] = []; + page.on("request", (req) => requests.push(req.url())); + + await page.goto("/objects/obj-roa-0001"); + await expect(page.getByText("65001")).toBeVisible(); + expect(requests.some((u) => u.includes("/chain"))).toBe(false); + expect(requests.some((u) => u.includes("/validation"))).toBe(false); + + await page.getByRole("tab", { name: "Chain" }).click(); + await expect.poll(() => requests.some((u) => u.includes("/chain"))).toBe(true); + await expect(page.getByText("issued_by")).toBeVisible(); + + await page.getByRole("tab", { name: "Validation" }).click(); + await expect.poll(() => requests.some((u) => u.includes("/validation"))).toBe(true); + await expect(page.getByText("Final status")).toBeVisible(); +}); + +test("validation explain POSTs and renders the audit-projection notice", async ({ page }) => { + await page.goto("/objects/obj-roa-0001?tab=validation"); + const posts: string[] = []; + page.on("request", (req) => { + if (req.method() === "POST" && req.url().includes("/validation/explain")) posts.push(req.url()); + }); + + await page.getByRole("button", { name: "Run explain" }).click(); + await expect.poll(() => posts.length).toBe(1); + await expect(page.getByText("audit_projection").first()).toBeVisible(); + await expect(page.getByText("not a full revalidation")).toBeVisible(); +}); + +test("manifest object renders the file list", async ({ page }) => { + await page.goto("/objects/obj-mft-0001"); + await expect(page.getByText("Manifest number")).toBeVisible(); + await expect(page.getByText("0A2F")).toBeVisible(); + await expect(page.getByRole("cell", { name: "alice.roa" })).toBeVisible(); +}); + +test("object without projection shows the explicit unavailable state", async ({ page }) => { + await page.goto("/objects/obj-asa-0001"); + await expect(page.getByText("Parsed projection unavailable")).toBeVisible(); +}); + +test("download raw triggers a browser download", async ({ page }) => { + await page.goto("/objects/obj-roa-0001"); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download raw" }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("alice.roa"); +}); + +test("export object set starts a job and offers the tarball when complete", async ({ page }) => { + await page.goto("/objects/obj-roa-0001"); + const posts: string[] = []; + page.on("request", (req) => { + if (req.method() === "POST" && req.url().includes("/exports")) posts.push(req.url()); + }); + + await page.getByRole("button", { name: "Export object set" }).click(); + await expect.poll(() => posts.length).toBe(1); + // Mock completes the job after the first poll. + await expect(page.getByRole("link", { name: "Download tar" })).toBeVisible({ timeout: 10_000 }); +}); + +test("missing object shows an error state", async ({ page }) => { + await page.goto("/objects/obj-does-not-exist"); + await expect(page.getByRole("alert")).toContainText("Failed to load object"); +}); diff --git a/ui/rpki-explorer/tests/e2e/objects.spec.ts b/ui/rpki-explorer/tests/e2e/objects.spec.ts new file mode 100644 index 0000000..ec63d73 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/objects.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; + +test.beforeEach(async ({ page }) => { + await installMockApi(page); +}); + +test("objects page lists all objects with server-side filters", async ({ page }) => { + const requests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/objects?")) requests.push(req.url()); + }); + + await page.goto("/objects"); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible(); + await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible(); + + // Type filter is sent to the backend and narrows the rendered list + await page.getByLabel("Type").selectOption("manifest"); + await expect.poll(() => requests.some((url) => url.includes("type=manifest"))).toBe(true); + await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible(); + await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeHidden(); + + // Rejected-only checkbox + await page.getByLabel("Type").selectOption(""); + await page.getByRole("checkbox", { name: "Rejected only" }).click(); + await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked(); + await expect.poll(() => requests.some((url) => url.includes("rejected=true"))).toBe(true); + await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden(); + + // URI substring filter + await page.getByRole("checkbox", { name: "Rejected only" }).click(); + await expect(page.getByRole("checkbox", { name: "Rejected only" })).not.toBeChecked(); + await page.getByLabel("URI contains").fill("repo-b"); + await page.getByLabel("URI contains").press("Enter"); + await expect.poll(() => requests.some((url) => url.includes("q=repo-b"))).toBe(true); + await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden(); +}); + +test("objects page deep link applies filters from the URL", async ({ page }) => { + await page.goto("/objects?type=certificate&rejected=true"); + await expect(page.getByLabel("Type")).toHaveValue("certificate"); + await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked(); + await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden(); +}); + +test("object row navigates to the object detail page", async ({ page }) => { + await page.goto("/objects"); + await page.getByText("rsync://repo-a.example/ca1/alice.roa").click(); + await expect(page).toHaveURL(/\/objects\/obj-roa-0001/); + await expect(page.getByText("ROA object")).toBeVisible(); +}); diff --git a/ui/rpki-explorer/tests/e2e/overview-api.spec.ts b/ui/rpki-explorer/tests/e2e/overview-api.spec.ts deleted file mode 100644 index 6c10227..0000000 --- a/ui/rpki-explorer/tests/e2e/overview-api.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; - -test("renders live overview data from query service without global object list", async ({ page }) => { - const apiRequests: string[] = []; - const consoleErrors: string[] = []; - - page.on("request", (request) => { - const url = new URL(request.url()); - if (url.pathname.startsWith("/api/v1")) { - apiRequests.push(`${url.pathname}${url.search}`); - } - }); - page.on("console", (message) => { - if (message.type() === "error") { - consoleErrors.push(message.text()); - } - }); - - await page.goto("/"); - - await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible(); - await expect(page.getByText(/run_\d+/).first()).toBeVisible(); - await expect(page.getByText("Top repositories by workload")).toBeVisible(); - await expect(page.locator(".issue-card").first()).toBeVisible(); - - expect(apiRequests).toContain("/api/v1/latest_run"); - expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); - expect(consoleErrors).toEqual([]); - - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true }); -}); diff --git a/ui/rpki-explorer/tests/e2e/overview.spec.ts b/ui/rpki-explorer/tests/e2e/overview.spec.ts new file mode 100644 index 0000000..d10294f --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/overview.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; + +test("overview renders run strip, KPIs, charts, top repos and reasons", async ({ page }) => { + await installMockApi(page); + await page.goto("/"); + + // Run strip + await expect(page.locator(".overview-run-strip").getByText("run_0002")).toBeVisible(); + await expect(page.getByText("delta sync")).toBeVisible(); + + // KPI cards with fixture values + await expect(page.getByText("VRPs")).toBeVisible(); + await expect(page.getByText("987")).toBeVisible(); + await expect(page.locator(".kpi-grid").getByText("Rejected")).toBeVisible(); + + // Charts render (recharts svg) + await expect(page.locator(".recharts-responsive-container").first()).toBeVisible(); + + // Top repositories table + await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible(); + + // Top reject reasons + await expect(page.getByText("certificate expired: notAfter")).toBeVisible(); +}); + +test("overview does not request the global object list (first-screen discipline)", async ({ + page, +}) => { + await installMockApi(page); + const objectListRequests: string[] = []; + page.on("request", (req) => { + const url = new URL(req.url()); + if (/\/api\/v1\/runs\/[^/]+\/objects$/.test(url.pathname)) { + objectListRequests.push(req.url()); + } + }); + await page.goto("/"); + await expect(page.getByText("987")).toBeVisible(); + expect(objectListRequests).toEqual([]); +}); + +test("overview drill-down: repo row navigates to repository detail", async ({ page }) => { + await installMockApi(page); + await page.goto("/"); + await page.getByRole("cell", { name: "repo-b.example" }).click(); + await expect(page).toHaveURL(/\/repositories\/repo-b2c3d4e5f6a7/); + await expect(page.getByRole("heading", { name: "repo-b.example" })).toBeVisible(); +}); + +test("overview drill-down: reason navigates to filtered validation page", async ({ page }) => { + await installMockApi(page); + await page.goto("/"); + await page.getByRole("link", { name: /manifest stale/ }).click(); + await expect(page).toHaveURL(/\/validation\?reason=manifest/); + await expect(page.getByRole("heading", { name: "Validation" })).toBeVisible(); +}); + +test("overview shows an explicit error state when the run cannot be loaded", async ({ page }) => { + await installMockApi(page, { failLatestRun: true }); + await page.goto("/"); + await expect(page.getByRole("alert")).toContainText("Failed to load the latest run"); +}); diff --git a/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts b/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts deleted file mode 100644 index 496fba6..0000000 --- a/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.setTimeout(120_000); -const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; - -test("loads repository, publication point, and object data on demand", async ({ page }) => { - const apiRequests: string[] = []; - const consoleErrors: string[] = []; - - page.on("request", (request) => { - const url = new URL(request.url()); - if (url.pathname.startsWith("/api/v1")) { - apiRequests.push(`${url.pathname}${url.search}`); - } - }); - page.on("console", (message) => { - if (message.type() === "error") { - consoleErrors.push(message.text()); - } - }); - - await page.goto("/"); - await page.getByRole("button", { name: /Repositories/i }).click(); - - await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); - await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible(); - expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false); - expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); - - const reposSection = page.locator('section[aria-label="Repositories"]'); - await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); - await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1"); - await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/); - await reposSection.getByRole("button", { name: "Collapse repositories column" }).click(); - await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories"); - await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible(); - await reposSection.getByRole("button", { name: "Expand repositories column" }).click(); - await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); - await expect(reposSection.locator(".stack-row")).toHaveCount(1); - await reposSection.locator(".stack-row-select").first().click(); - - const ppSection = page.locator('section[aria-label="Publication points"]'); - await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); - await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1"); - await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/); - await ppSection.getByRole("button", { name: "Collapse publication points column" }).click(); - await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible(); - await ppSection.getByRole("button", { name: "Expand publication points column" }).click(); - expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); - - await ppSection.locator(".stack-row-select").first().click(); - const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); - await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 }); - expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true); - await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible(); - await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible(); - await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible(); - await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible(); - await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible(); - await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1"); - await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/); - expect(consoleErrors).toEqual([]); - - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true }); -}); - -test("keeps selected repository and publication point after opening object detail", async ({ page }) => { - await page.goto("/"); - await page.getByRole("button", { name: /Repositories/i }).click(); - - const reposSection = page.locator('section[aria-label="Repositories"]'); - await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); - await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 }); - const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim(); - await reposSection.locator(".stack-row-select").first().click(); - - const ppSection = page.locator('section[aria-label="Publication points"]'); - await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); - const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim(); - await ppSection.locator(".stack-row-select").first().click(); - - const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); - await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 }); - const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim(); - await objectsPanel.getByRole("button", { name: "Open" }).first().click(); - await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 }); - - await page.getByRole("button", { name: /Repositories/i }).click(); - await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); - await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya"); - await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri); - await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri); - await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri); -}); diff --git a/ui/rpki-explorer/tests/e2e/repositories.spec.ts b/ui/rpki-explorer/tests/e2e/repositories.spec.ts new file mode 100644 index 0000000..34cfc46 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/repositories.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; +import { REPOS } from "./fixtures"; + +test.beforeEach(async ({ page }) => { + await installMockApi(page); +}); + +test("repository list renders rows and filters the current page", async ({ page }) => { + await page.goto("/repositories"); + await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible(); + + await page.getByLabel("Filter current page").fill("repo-b"); + await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeHidden(); + await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible(); +}); + +test("repository list paginates with cursors", async ({ page }) => { + // Register a 2-page dataset on top of the base mock (later routes win). + await page.route("**/api/v1/runs/*/repos?**", (route) => { + const url = new URL(route.request().url()); + const cursor = url.searchParams.get("cursor"); + const items = cursor === "2" ? REPOS.slice(2) : REPOS.slice(0, 2); + return route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: items, + page: { nextCursor: cursor === "2" ? null : "2", limit: 50 }, + meta: { runId: "run_0002", schemaVersion: 1 }, + }), + }); + }); + + await page.goto("/repositories"); + await expect(page.getByText("Page 1")).toBeVisible(); + await page.getByRole("button", { name: "Next" }).click(); + await expect(page.getByText("Page 2")).toBeVisible(); + await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible(); + await page.getByRole("button", { name: "Previous" }).click(); + await expect(page.getByText("Page 1")).toBeVisible(); + await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible(); +}); + +test("repository detail: tabs switch between PPs and objects", async ({ page }) => { + await page.goto("/repositories/repo-a1b2c3d4e5f6"); + + await expect(page.getByRole("heading", { name: "repo-a.example" })).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible(); + + // Objects tab is lazy: no repo-scoped objects request before activation + const objectRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/repos/repo-a1b2c3d4e5f6/objects")) objectRequests.push(req.url()); + }); + + await page.getByRole("tab", { name: "Objects" }).click(); + await expect(page.getByLabel("URI contains")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible(); + expect(objectRequests.length).toBeGreaterThan(0); + + await page.getByRole("tab", { name: "Publication points" }).click(); + await expect(page.getByText("rsync://repo-a.example/ca1b/manifest.mft")).toBeVisible(); +}); + +test("publication point detail shows metadata and its objects", async ({ page }) => { + await page.goto("/publication-points/node_3"); + await expect(page.getByText("rsync://repo-b.example/ca2/manifest.mft").first()).toBeVisible(); + await expect(page.getByText("terminal cached")).toBeVisible(); + await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible(); +}); + +test("publication points list navigates to detail", async ({ page }) => { + await page.goto("/publication-points"); + await page.getByText("rsync://repo-b.example/ca2/manifest.mft").click(); + await expect(page).toHaveURL(/\/publication-points\/node_3/); +}); diff --git a/ui/rpki-explorer/tests/e2e/shell.spec.ts b/ui/rpki-explorer/tests/e2e/shell.spec.ts index 6a958b3..9953ec0 100644 --- a/ui/rpki-explorer/tests/e2e/shell.spec.ts +++ b/ui/rpki-explorer/tests/e2e/shell.spec.ts @@ -1,83 +1,101 @@ import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; -const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; - -function collectConsoleErrors(page: import("@playwright/test").Page) { - const consoleErrors: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") { - const text = message.text(); - if (!text.includes("Failed to load resource")) { - consoleErrors.push(text); - } - } - }); - return consoleErrors; -} - -test("opens the RPKI Explorer overview", async ({ page }) => { - const consoleErrors = collectConsoleErrors(page); - - await page.goto("/"); - - await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible(); - await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible(); - await expect(page.getByLabel("Current run")).toContainText("latest indexed run"); - await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled(); - await expect(page.getByText("Top repositories by workload")).toBeVisible(); - await expect(page.getByText("Recent validation issues")).toBeVisible(); - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true }); - expect(consoleErrors).toEqual([]); +test.beforeEach(async ({ page }) => { + await installMockApi(page); }); -test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => { - const consoleErrors = collectConsoleErrors(page); - +test("shell renders navigation, brand and connection status", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); + await expect(page.getByText("RPKI Explorer")).toBeVisible(); - await page.getByRole("button", { name: "Collapse navigation" }).click(); - await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/); - await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible(); - await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute"); - await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute"); - await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible(); - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true }); - - await page.getByRole("button", { name: "Repositories" }).click(); - await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); - - await page.getByRole("button", { name: "Expand navigation" }).click(); - await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/); - await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); - expect(consoleErrors).toEqual([]); -}); - -test("opens truthful placeholders for unfinished navigation", async ({ page }) => { - const consoleErrors = collectConsoleErrors(page); - await page.goto("/"); - - for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) { - await page.getByRole("button", { name }).click(); - await expect(page.locator("#coming-soon-heading")).toHaveText(name); - await expect(page.getByText("Coming soon", { exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0); + const nav = page.getByRole("navigation", { name: "Primary" }); + for (const label of [ + "Overview", + "Repositories", + "Publication Points", + "Objects", + "Validation", + "Exports", + "Runs", + "API", + ]) { + await expect(nav.getByRole("link", { name: label })).toBeVisible(); } - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true }); - expect(consoleErrors).toEqual([]); + await expect(page.getByText("connected")).toBeVisible(); }); -test("objects page starts from explicit empty state", async ({ page }) => { - const consoleErrors = collectConsoleErrors(page); +test("every nav entry navigates to a real page (no dead controls)", async ({ page }) => { + await page.goto("/"); + const nav = page.getByRole("navigation", { name: "Primary" }); + + const cases: { label: string; path: RegExp; heading: string | RegExp }[] = [ + { label: "Repositories", path: /\/repositories$/, heading: "Repositories" }, + { label: "Publication Points", path: /\/publication-points$/, heading: "Publication Points" }, + { label: "Objects", path: /\/objects$/, heading: "Objects" }, + { label: "Validation", path: /\/validation$/, heading: "Validation" }, + { label: "Exports", path: /\/exports$/, heading: "Exports" }, + { label: "Runs", path: /\/runs$/, heading: "Runs" }, + { label: "API", path: /\/api$/, heading: "API" }, + ]; + + for (const { label, path, heading } of cases) { + await nav.getByRole("link", { name: label, exact: true }).click(); + await expect(page).toHaveURL(path); + await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible(); + } +}); + +test("run selector pins the run via ?run= and pages use it", async ({ page }) => { + const requests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/api/v1/runs/")) requests.push(req.url()); + }); await page.goto("/"); - await page.getByRole("button", { name: /Objects/i }).click(); - - await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible(); - await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible(); - await expect(page.locator("body")).not.toContainText("sample PP"); - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true }); - expect(consoleErrors).toEqual([]); + const selector = page.getByLabel("Run"); + await expect(selector).toBeVisible(); + await selector.selectOption("run_0001"); + await expect(page).toHaveURL(/run=run_0001/); + await expect.poll(() => requests.some((url) => url.includes("/runs/run_0001/"))).toBe(true); +}); + +test("topbar search routes to the search page with the query", async ({ page }) => { + await page.goto("/"); + const input = page.getByRole("searchbox", { name: "Global search" }); + await input.fill("repo-a.example"); + await input.press("Enter"); + await expect(page).toHaveURL(/\/search\?q=repo-a/); + await expect(page.getByRole("heading", { name: "Search", exact: true })).toBeVisible(); +}); + +test("unknown routes render the 404 page", async ({ page }) => { + await page.goto("/definitely-not-a-page"); + await expect(page.getByText("404")).toBeVisible(); + await page.getByRole("link", { name: "Back to Overview" }).click(); + await expect(page).toHaveURL(/\/$/); +}); + +test("deep links restore page state after reload", async ({ page }) => { + await page.goto("/objects?type=manifest"); + await expect(page).toHaveURL(/type=manifest/); + await expect(page.getByLabel("Type")).toHaveValue("manifest"); + await page.reload(); + await expect(page.getByLabel("Type")).toHaveValue("manifest"); +}); + +test("runs page lists runs, pins a run and expands artifacts", async ({ page }) => { + await page.goto("/runs"); + await expect(page.getByRole("cell", { name: "run_0002" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "run_0001" })).toBeVisible(); + + // Expand artifacts for run_0001 + const row = page.getByRole("row", { name: /run_0001/ }); + await row.getByRole("button", { name: "Artifacts" }).click(); + await expect(page.getByText("/data/runs/run_0001/report.json")).toBeVisible(); + + // Pin run_0001 — navigates to overview with ?run=run_0001 + await row.getByRole("button", { name: "Use run" }).click(); + await expect(page).toHaveURL(/run=run_0001/); }); diff --git a/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts b/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts new file mode 100644 index 0000000..5ea8e40 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; + +test.beforeEach(async ({ page }) => { + await installMockApi(page); +}); + +test("validation page: issues table, distributions and reason filter", async ({ page }) => { + const issueRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/issues")) issueRequests.push(req.url()); + }); + + await page.goto("/validation"); + + // Both rejected objects are listed + await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible(); + await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible(); + + // Result distribution + await expect(page.getByText("Result distribution")).toBeVisible(); + + // Click a reject-reason row → URL param + filtered request + await page.getByRole("button", { name: /manifest stale/ }).click(); + await expect(page).toHaveURL(/reason=manifest/); + await expect.poll(() => issueRequests.some((u) => u.includes("reason=manifest"))).toBe(true); + await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeHidden(); + + // Clear filters + await page.getByRole("link", { name: "Clear filters" }).click(); + await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible(); +}); + +test("search page: grouped results and navigation", async ({ page }) => { + await page.goto("/search?q=repo-a.example"); + await expect(page.getByText("Repositories (1)")).toBeVisible(); + await expect(page.getByText("Publication points (2)")).toBeVisible(); + + await page.getByRole("link", { name: /repo-a.example/ }).first().click(); + await expect(page).toHaveURL(/\/repositories\/repo-a1b2c3d4e5f6|\/publication-points\/node_/); +}); + +test("search page: exact URI finds the object", async ({ page }) => { + await page.goto("/search?q=rsync://repo-a.example/ca1/alice.roa"); + await expect(page.getByText("Objects (1)")).toBeVisible(); + await page.getByRole("link", { name: /alice.roa/ }).click(); + await expect(page).toHaveURL(/\/objects\/obj-roa-0001/); +}); + +test("search page: ASN/prefix queries show the #070 capability notice", async ({ page }) => { + await page.goto("/search?q=AS65001"); + await expect(page.getByRole("status").filter({ hasText: "feature #070" })).toBeVisible(); +}); + +test("search page: empty result state", async ({ page }) => { + await page.goto("/search?q=nothing-matches-this"); + await expect(page.getByText(/No results for/)).toBeVisible(); +}); + +test("exports page: seeded job completes and offers a download", async ({ page }) => { + const mock = await installMockApi(page); + mock.seedJob({ status: "running" }); + + await page.goto("/exports"); + await expect(page.getByText("job-0001")).toBeVisible(); + // Job transitions to complete after the poll interval + await expect(page.getByRole("link", { name: "Download" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("2.0 KiB")).toBeVisible(); +}); diff --git a/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts b/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts deleted file mode 100644 index 872d8ec..0000000 --- a/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.setTimeout(120_000); -const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; - -async function openFirstRepositoryObject(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByRole("button", { name: /Repositories/i }).click(); - const reposSection = page.locator('section[aria-label="Repositories"]'); - await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); - await reposSection.locator(".stack-row-select").first().click(); - await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click(); - const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first(); - await expect(row).toBeVisible({ timeout: 70_000 }); - const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim(); - await row.getByRole("button", { name: "Open" }).click(); - await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 }); -} - -test("supports URI search and reports raw/export workflow status", async ({ page }) => { - const apiRequests: string[] = []; - page.on("request", (request) => { - const url = new URL(request.url()); - if (url.pathname.startsWith("/api/v1")) { - apiRequests.push(`${request.method()} ${url.pathname}${url.search}`); - } - }); - - await openFirstRepositoryObject(page); - - await page.getByRole("button", { name: "Use selected URI" }).click(); - await page.getByPlaceholder("Exact URI lookup...").press("Enter"); - await expect.poll( - () => apiRequests.some((request) => request.includes("/objects/by-uri")), - { timeout: 30_000 } - ).toBe(true); - - await page.getByRole("button", { name: "Download raw" }).click(); - await expect.poll( - () => apiRequests.some((request) => request.includes("/raw")), - { timeout: 30_000 } - ).toBe(true); - - await page.getByRole("button", { name: "Export object" }).click(); - await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true); - - await page.getByRole("button", { name: "Export selected PP" }).click(); - await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 }); - - await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true }); -}); diff --git a/ui/rpki-explorer/vitest.config.ts b/ui/rpki-explorer/vitest.config.ts new file mode 100644 index 0000000..ce36a74 --- /dev/null +++ b/ui/rpki-explorer/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +});