From cf68abf642587332e89f1ac7a6288d22a2dbba7d Mon Sep 17 00:00:00 2001 From: yuyr Date: Mon, 27 Jul 2026 13:05:56 +0800 Subject: [PATCH] =?UTF-8?q?20260726=20=E4=BF=AE=E5=A4=8Drpki-explorer?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E9=97=AE=E9=A2=9832=E9=A1=B9(#130)=EF=BC=9AR?= =?UTF-8?q?ocksDB=20max=5Fopen=5Ffiles+compose=20ulimits=E3=80=81objects?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=B5=81=E5=BC=8F=E6=97=A9=E5=81=9C+scope?= =?UTF-8?q?=E5=89=8D=E7=BD=AE(=E6=AF=8F=E9=A1=B52.85s->10ms=E7=BA=A7)?= =?UTF-8?q?=E3=80=81=E6=9C=AA=E7=9F=A5/=E8=BF=87=E6=9C=9Frun=E7=BB=9F?= =?UTF-8?q?=E4=B8=80404=E3=80=81runs=E5=80=92=E5=BA=8F=E3=80=81=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=96=87=E4=BB=B6=E5=90=8DContent-Disposition?= =?UTF-8?q?=E3=80=81=E8=B7=AF=E5=BE=84=E6=AE=B5percent=5Fdecode=E3=80=81ng?= =?UTF-8?q?inx=20/api/v1=E4=BB=A3=E7=90=86=E4=B8=8E400=E5=9B=9ESPA?= =?UTF-8?q?=EF=BC=9B=E5=89=8D=E7=AB=AFserialNumber=20schema=E3=80=8130s?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=B6=85=E6=97=B6=E3=80=81reason=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E3=80=81=E7=BB=84=E5=90=88VRP=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E3=80=81=E5=AF=BC=E5=87=BA=E5=8F=8D=E9=A6=88=E4=B8=8Erepo/PP?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=80=81=E9=94=AE=E7=9B=98=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E3=80=81cursor=E8=BF=9BURL=E3=80=81=E5=88=86=E9=A1=B5/?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=BA=A6/skip-link=E7=AD=89=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/rpki-explorer/docker-compose.yml | 6 + deploy/rpki-explorer/nginx.conf | 21 ++ src/bin/rpki_query_service.rs | 204 +++++++++++-- src/blob_store.rs | 4 + src/query/report_stream.rs | 267 +++++++++++++++++- src/query_db.rs | 99 ++++++- ui/rpki-explorer/src/api/client.ts | 15 +- ui/rpki-explorer/src/api/schemas.ts | 3 +- ui/rpki-explorer/src/api/service.ts | 3 +- ui/rpki-explorer/src/components/DataTable.tsx | 13 + .../src/components/ObjectsTable.tsx | 63 ++++- .../src/components/ProjectionView.tsx | 8 +- ui/rpki-explorer/src/components/Shell.tsx | 7 +- ui/rpki-explorer/src/components/Tabs.tsx | 4 +- ui/rpki-explorer/src/lib/cursor.ts | 68 ++++- ui/rpki-explorer/src/lib/format.test.ts | 8 + ui/rpki-explorer/src/lib/format.ts | 7 +- ui/rpki-explorer/src/lib/useExportJob.ts | 34 +++ ui/rpki-explorer/src/lib/useRun.ts | 16 +- ui/rpki-explorer/src/lib/vrpQuery.test.ts | 30 ++ ui/rpki-explorer/src/lib/vrpQuery.ts | 24 +- ui/rpki-explorer/src/pages/ApiStatusPage.tsx | 1 - ui/rpki-explorer/src/pages/ExportsPage.tsx | 19 +- .../src/pages/ObjectDetailPage.tsx | 32 +-- ui/rpki-explorer/src/pages/ObjectsPage.tsx | 26 +- ui/rpki-explorer/src/pages/OverviewPage.tsx | 20 +- .../src/pages/PublicationPointDetailPage.tsx | 45 ++- .../src/pages/PublicationPointsPage.tsx | 3 +- .../src/pages/RepositoriesPage.tsx | 8 +- .../src/pages/RepositoryDetailPage.tsx | 42 ++- ui/rpki-explorer/src/pages/RunsPage.tsx | 2 + ui/rpki-explorer/src/pages/SearchPage.tsx | 30 +- ui/rpki-explorer/src/pages/ValidationPage.tsx | 17 +- ui/rpki-explorer/src/styles/components.css | 13 + ui/rpki-explorer/src/styles/shell.css | 43 +++ ui/rpki-explorer/src/styles/tokens.css | 2 +- 36 files changed, 1082 insertions(+), 125 deletions(-) create mode 100644 ui/rpki-explorer/src/lib/useExportJob.ts diff --git a/deploy/rpki-explorer/docker-compose.yml b/deploy/rpki-explorer/docker-compose.yml index a72a298..d74e9a6 100644 --- a/deploy/rpki-explorer/docker-compose.yml +++ b/deploy/rpki-explorer/docker-compose.yml @@ -15,6 +15,12 @@ services: GIT_REV: ${GIT_REV:-unknown} container_name: rpki-explorer restart: unless-stopped + # RocksDB keeps table files open across 13 column families; the default + # 1024 nofile limit is too tight under concurrent reads. + ulimits: + nofile: + soft: 65536 + hard: 65536 ports: # Loopback only: access via SSH port forwarding, e.g. # ssh -L 9517:127.0.0.1:9517 root@ diff --git a/deploy/rpki-explorer/nginx.conf b/deploy/rpki-explorer/nginx.conf index de0918c..f5eed50 100644 --- a/deploy/rpki-explorer/nginx.conf +++ b/deploy/rpki-explorer/nginx.conf @@ -8,6 +8,27 @@ server { root /usr/share/nginx/html; index index.html; + # Keep API/proxy redirects relative so port-forwarded access (ssh -L) is + # not bounced to an unreachable absolute URL. + absolute_redirect off; + port_in_redirect off; + + # Hand-typed API URLs that fail to parse (e.g. raw % in a path segment) + # should land on the SPA instead of nginx's default 400 page. + error_page 400 /index.html; + + # `/api/v1` without trailing slash must reach the service info endpoint + # too, not the SPA fallback. + location = /api/v1 { + proxy_pass http://127.0.0.1:9557; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + # query service has no CORS headers and no authentication: the API must be # served same-origin with the UI. location /api/v1/ { diff --git a/src/bin/rpki_query_service.rs b/src/bin/rpki_query_service.rs index 6395c01..66f1b1c 100644 --- a/src/bin/rpki_query_service.rs +++ b/src/bin/rpki_query_service.rs @@ -542,7 +542,18 @@ impl ApiError { impl From for ApiError { fn from(value: QueryDbError) -> Self { - Self::new(500, value.to_string()) + match value { + // The run is still indexed but its report.json was removed by + // retention cleanup: this is a client-visible "gone", not a + // server fault. + QueryDbError::RunDataGone(run_id) => Self::new( + 404, + format!( + "report data for run {run_id} has expired and was cleaned up by retention" + ), + ), + other => Self::new(500, other.to_string()), + } } } @@ -555,11 +566,10 @@ fn route_request( let (path, query) = split_target(target); let query = parse_query(query); let path = path.trim_end_matches('/'); - let segments = path - .trim_start_matches("/api/v1") - .trim_start_matches('/') - .split('/') - .filter(|s| !s.is_empty()) + let decoded_segments = decode_path_segments(path); + let segments = decoded_segments + .iter() + .map(String::as_str) .collect::>(); if segments.is_empty() { return Ok(json!({"data":{"service":"rpki_query_service","version":1}})); @@ -1002,11 +1012,10 @@ fn route_post_request( ) -> Result { let (path, _) = split_target(target); let path = path.trim_end_matches('/'); - let segments = path - .trim_start_matches("/api/v1") - .trim_start_matches('/') - .split('/') - .filter(|s| !s.is_empty()) + let decoded_segments = decode_path_segments(path); + let segments = decoded_segments + .iter() + .map(String::as_str) .collect::>(); match segments.as_slice() { ["runs", raw_run_id, "exports"] => { @@ -1159,11 +1168,10 @@ fn route_raw_request( ) -> Option, ApiError>> { let (path, _) = split_target(target); let path = path.trim_end_matches('/'); - let segments = path - .trim_start_matches("/api/v1") - .trim_start_matches('/') - .split('/') - .filter(|s| !s.is_empty()) + let decoded_segments = decode_path_segments(path); + let segments = decoded_segments + .iter() + .map(String::as_str) .collect::>(); match segments.as_slice() { ["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response( @@ -1224,7 +1232,20 @@ fn export_download_response( .as_ref() .ok_or_else(|| ApiError::new(404, "export output path not found"))?; let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?; - Ok(binary_response(200, "application/x-tar", bytes)) + let filename = format!( + "rpki-export-{}-{}.tar", + sanitize_filename_token(&run_id), + sanitize_filename_token(job_id) + ); + Ok(binary_response_with_headers( + 200, + "application/x-tar", + &[( + "Content-Disposition".to_string(), + format!("attachment; filename=\"{filename}\""), + )], + bytes, + )) } fn export_job_store_key(run_id: &str, job_id: &str) -> String { @@ -2098,6 +2119,18 @@ fn hex_value(byte: u8) -> Option { } } +/// Split an API path into percent-decoded segments. Real-world ids are plain +/// hex, so decoding is a no-op for them; it only matters for hand-built URLs +/// (e.g. an id containing a literal `%2F`). +fn decode_path_segments(path: &str) -> Vec { + path.trim_start_matches("/api/v1") + .trim_start_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .map(|segment| percent_decode(segment).unwrap_or_else(|| segment.to_string())) + .collect() +} + fn limit(query: &BTreeMap) -> usize { query .get("limit") @@ -2248,13 +2281,26 @@ fn json_response(status: u16, value: &Value) -> Vec { } fn binary_response(status: u16, content_type: &str, body: Vec) -> Vec { + binary_response_with_headers(status, content_type, &[], body) +} + +fn binary_response_with_headers( + status: u16, + content_type: &str, + extra_headers: &[(String, String)], + body: Vec, +) -> Vec { let reason = match status { 200 => "OK", 404 => "Not Found", _ => "Internal Server Error", }; + let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n"); + for (name, value) in extra_headers { + head.push_str(&format!("{name}: {value}\r\n")); + } let mut response = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "{head}Content-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) .into_bytes(); @@ -2262,6 +2308,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec) -> Vec { response } +/// Header-safe token for use inside a `Content-Disposition` filename: strips +/// anything that could smuggle CRLF or quotes into the response head. +fn sanitize_filename_token(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + .collect() +} + fn is_sha256_hex(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } @@ -2626,6 +2681,94 @@ mod tests { ); } + fn index_sample_run(temp: &tempfile::TempDir) -> QueryDb { + let run_dir = temp.path().join("runs/run_0001"); + fs::create_dir_all(&run_dir).expect("run dir"); + let object_bytes = + fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") + .expect("fixture roa"); + let object_sha = hex::encode(Sha256::digest(&object_bytes)); + write_sample_run(&run_dir, &object_sha); + let query_db_path = temp.path().join("query-db"); + index_artifacts(&ArtifactIndexerConfig { + query_db_path: query_db_path.clone(), + run_root: Some(temp.path().to_path_buf()), + run_dir: None, + repo_bytes_db_path: None, + projection_entry_limit: 5, + min_run_seq: None, + retain_indexed_runs: None, + }) + .expect("index artifacts"); + QueryDb::open(&query_db_path).expect("query db") + } + + #[test] + fn unknown_run_id_returns_404_instead_of_empty_payload() { + let temp = tempfile::tempdir().expect("tempdir"); + let db = index_sample_run(&temp); + + for target in [ + "/api/v1/runs/run_9999", + "/api/v1/runs/run_9999/summary", + "/api/v1/runs/run_9999/objects", + "/api/v1/runs/run_9999/issues", + "/api/v1/runs/run_9999/stats/overview", + "/api/v1/runs/run_9999/stats/validation-events?name=manifest", + ] { + let err = match test_route_request(&db, None, target) { + Ok(_) => panic!("expected 404 for {target}"), + Err(err) => err, + }; + assert_eq!(err.status, 404, "{target}"); + } + // Existing runs and "latest" keep working. + assert!( + test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok() + ); + assert!(test_route_request(&db, None, "/api/v1/runs/latest/objects").is_ok()); + } + + #[test] + fn expired_run_report_returns_404_run_data_gone() { + let temp = tempfile::tempdir().expect("tempdir"); + let db = index_sample_run(&temp); + assert!(test_route_request(&db, None, "/api/v1/runs/run_0001/objects").is_ok()); + + // Retention cleanup removes the run directory while the index record + // survives: object routes must report a distinguishable 404. + fs::remove_dir_all(temp.path().join("runs/run_0001")).expect("remove run dir"); + let err = test_route_request(&db, None, "/api/v1/runs/run_0001/objects") + .expect_err("run data gone"); + assert_eq!(err.status, 404); + assert!( + err.message.contains("expired"), + "unexpected message: {}", + err.message + ); + let err = test_route_request(&db, None, "/api/v1/runs/run_0001/issues") + .expect_err("run data gone"); + assert_eq!(err.status, 404); + // Stats served from the index keep working. + assert!( + test_route_request(&db, None, "/api/v1/runs/run_0001/stats/overview").is_ok() + ); + } + + #[test] + fn decode_path_segments_percent_decodes_ids() { + let segments = decode_path_segments("/api/v1/runs/run_0001/objects/abc%2Fdef"); + assert_eq!(segments, vec!["runs", "run_0001", "objects", "abc/def"]); + assert!(decode_path_segments("/api/v1").is_empty()); + assert!(decode_path_segments("/api/v1/").is_empty()); + } + + #[test] + fn sanitize_filename_token_strips_crlf_and_quotes() { + assert_eq!(sanitize_filename_token("run_0001"), "run_0001"); + assert_eq!(sanitize_filename_token("a\r\nb\"c d/e"), "abcde"); + } + #[test] fn raw_route_downloads_object_bytes_from_repo_bytes_db() { let temp = tempfile::tempdir().expect("tempdir"); @@ -2813,6 +2956,31 @@ mod tests { assert_eq!(job.status, "complete"); assert_eq!(job.object_count, 1); assert!(job.bytes_written > 0); + + let download = test_route_raw_request( + &db, + None, + &format!("/api/v1/runs/latest/exports/{job_id}/download"), + ) + .expect("download route") + .expect("download response"); + let head_end = download + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("header separator"); + let head = String::from_utf8_lossy(&download[..head_end]); + assert!(head.starts_with("HTTP/1.1 200 OK"), "head: {head}"); + assert!( + head.contains("Content-Type: application/x-tar\r\n"), + "head: {head}" + ); + let expected_disposition = format!( + "Content-Disposition: attachment; filename=\"rpki-export-run_0001-{job_id}.tar\"" + ); + assert!( + head.contains(&expected_disposition), + "head: {head}" + ); } #[test] diff --git a/src/blob_store.rs b/src/blob_store.rs index df01be1..f840a0a 100644 --- a/src/blob_store.rs +++ b/src/blob_store.rs @@ -90,6 +90,7 @@ impl ExternalRawStoreDb { let mut opts = Options::default(); opts.create_if_missing(true); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { path, @@ -187,6 +188,7 @@ impl ExternalRepoBytesDb { let mut opts = Options::default(); opts.create_if_missing(true); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { path, @@ -200,6 +202,7 @@ impl ExternalRepoBytesDb { let path = path.into(); let mut opts = Options::default(); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let db = DB::open_for_read_only(&opts, &path, false) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { @@ -227,6 +230,7 @@ impl ExternalRepoBytesDb { } let mut opts = Options::default(); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let db = DB::open_as_secondary(&opts, &path, &secondary_path) .map_err(|e| StorageError::RocksDb(e.to_string()))?; Ok(Self { diff --git a/src/query/report_stream.rs b/src/query/report_stream.rs index 466c143..62f41ed 100644 --- a/src/query/report_stream.rs +++ b/src/query/report_stream.rs @@ -176,9 +176,14 @@ pub fn list_report_objects_filtered( start_pp_index, start_object_index, ); - ObjectScanSeed { state: &mut state } - .deserialize(&mut deserializer) - .map_err(QueryDbError::from)?; + let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer); + match scan { + Ok(()) => {} + // A full page plus its next cursor is all the caller needs; the + // visitor aborts the remaining scan with a sentinel error. + Err(err) if is_object_scan_complete(&err) => {} + Err(err) => return Err(QueryDbError::from(err)), + } Ok(QueryPage { data: state.data, next_cursor: state.next_cursor, @@ -215,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String { format!("r1:{pp_index}:{object_index}") } +/// Sentinel message used to abort the streaming object scan early once a list +/// page (plus its next cursor) has been collected. The deserializer is left +/// mid-document and dropped, which is fine because the caller only needs the +/// state accumulated so far. +const OBJECT_SCAN_COMPLETE_MSG: &str = "rpki report object scan complete: page collected"; + +fn is_object_scan_complete(err: &serde_json::Error) -> bool { + err.is_data() && err.to_string().starts_with(OBJECT_SCAN_COMPLETE_MSG) +} + fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> { let Some(cursor) = cursor else { return Ok((0, 0)); @@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> { scanned_objects: 0, } } + + /// True once a list page is full and its next cursor is fixed; everything + /// after this point cannot alter the page (cursor semantics: the cursor + /// already points at the first item of the next page). + fn list_page_complete(&self) -> bool { + matches!(self.mode, ObjectScanMode::List) + && self.data.len() >= self.limit + && self.next_cursor.is_some() + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> { A: SeqAccess<'de>, { let mut pp_index = 0u64; - while let Some(raw_pp) = seq.next_element::>()? { + loop { + if self.state.list_page_complete() { + return Err(de::Error::custom(OBJECT_SCAN_COMPLETE_MSG)); + } + let Some(raw_pp) = seq.next_element::>()? else { + break; + }; process_raw_publication_point(self.state, pp_index, raw_pp.get()) .map_err(de::Error::custom)?; pp_index += 1; @@ -822,6 +852,20 @@ fn process_raw_publication_point( raw_pp: &str, ) -> QueryDbResult<()> { state.scanned_publication_points += 1; + if matches!(state.mode, ObjectScanMode::List) && pp_index < state.start_pp_index { + // Every object in this PP sits before the cursor, so none of them can + // be part of the page: skip materializing the subtree entirely. + return Ok(()); + } + if state.scope != ObjectScope::All { + // Scope is decidable from PP-level fields alone; probe them before + // materializing the (potentially huge) objects subtree. + let probe: PublicationPointScopeProbe = serde_json::from_str(raw_pp)?; + let ctx = PpObjectContext::from_pp(state.run_id, &probe.into_summary()); + if !state.scope.matches(&ctx) { + return Ok(()); + } + } let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?; let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary); if !state.scope.matches(&ctx) { @@ -909,6 +953,30 @@ impl<'de> Visitor<'de> for PublicationPointForObjectsVisitor { } } +/// Lightweight probe of the PP-level fields needed to decide `ObjectScope` +/// before the (potentially huge) objects subtree is materialized. +#[derive(Clone, Debug, Default, Deserialize)] +struct PublicationPointScopeProbe { + node_id: Option, + rsync_base_uri: Option, + manifest_rsync_uri: Option, + publication_point_rsync_uri: Option, + rrdp_notification_uri: Option, +} + +impl PublicationPointScopeProbe { + fn into_summary(self) -> PublicationPointSummary { + PublicationPointSummary { + node_id: self.node_id, + rsync_base_uri: self.rsync_base_uri, + manifest_rsync_uri: self.manifest_rsync_uri, + publication_point_rsync_uri: self.publication_point_rsync_uri, + rrdp_notification_uri: self.rrdp_notification_uri, + ..PublicationPointSummary::default() + } + } +} + #[derive(Clone, Debug, Default)] struct ReportObject { uri: String, @@ -1360,4 +1428,195 @@ mod tests { assert_eq!(found.object.result, "error"); assert_eq!(found.resolution.mode, "report_scan"); } + + /// Three PPs with two objects each; used to anchor paging and scope + /// behavior of the early-stop object scan. + fn write_multi_pp_report(report_path: &std::path::Path) { + fs::write( + report_path, + r#"{ + "meta":{"validation_time_rfc3339_utc":"2026-06-16T00:00:00Z"}, + "tree":{"warnings":[]}, + "publication_points":[ + { + "node_id":1, + "rsync_base_uri":"rsync://repo-a.example/rpki/", + "manifest_rsync_uri":"rsync://repo-a.example/rpki/m.mft", + "publication_point_rsync_uri":"rsync://repo-a.example/rpki/", + "rrdp_notification_uri":"https://repo-a.example/rrdp/notification.xml", + "objects":[ + {"rsync_uri":"rsync://repo-a.example/rpki/m.mft","sha256_hex":"a1","kind":"manifest","result":"ok"}, + {"rsync_uri":"rsync://repo-a.example/rpki/a.roa","sha256_hex":"a2","kind":"roa","result":"ok"} + ] + }, + { + "node_id":2, + "rsync_base_uri":"rsync://repo-b.example/rpki/", + "manifest_rsync_uri":"rsync://repo-b.example/rpki/m.mft", + "publication_point_rsync_uri":"rsync://repo-b.example/rpki/", + "objects":[ + {"rsync_uri":"rsync://repo-b.example/rpki/m.mft","sha256_hex":"b1","kind":"manifest","result":"ok"}, + {"rsync_uri":"rsync://repo-b.example/rpki/b.roa","sha256_hex":"b2","kind":"roa","result":"error","detail":"bad roa"} + ] + }, + { + "node_id":3, + "rsync_base_uri":"rsync://repo-c.example/rpki/", + "manifest_rsync_uri":"rsync://repo-c.example/rpki/m.mft", + "publication_point_rsync_uri":"rsync://repo-c.example/rpki/", + "rrdp_notification_uri":"https://repo-c.example/rrdp/notification.xml", + "objects":[ + {"rsync_uri":"rsync://repo-c.example/rpki/m.mft","sha256_hex":"c1","kind":"manifest","result":"ok"}, + {"rsync_uri":"rsync://repo-c.example/rpki/c.roa","sha256_hex":"c2","kind":"roa","result":"ok"} + ] + } + ] + }"#, + ) + .expect("report"); + } + + #[test] + fn object_listing_pages_across_publication_points_with_stable_cursors() { + let temp = tempfile::tempdir().expect("tempdir"); + let report_path = temp.path().join("report.json"); + write_multi_pp_report(&report_path); + + // Page 1: fills inside PP 0; the cursor must resume at PP 0 object 2. + let page1 = list_report_objects(&report_path, "run_0001", ObjectScope::All, 2, None) + .expect("page1"); + assert_eq!(page1.data.len(), 2); + assert_eq!(page1.data[0].uri, "rsync://repo-a.example/rpki/m.mft"); + assert_eq!(page1.data[1].uri, "rsync://repo-a.example/rpki/a.roa"); + assert_eq!(page1.next_cursor.as_deref(), Some("r1:0:2")); + + // Page 2: PP 0 is exhausted; the two PP 1 objects fill the page. + let page2 = list_report_objects( + &report_path, + "run_0001", + ObjectScope::All, + 2, + page1.next_cursor.as_deref(), + ) + .expect("page2"); + assert_eq!(page2.data.len(), 2); + assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/m.mft"); + assert_eq!(page2.data[1].uri, "rsync://repo-b.example/rpki/b.roa"); + assert_eq!(page2.next_cursor.as_deref(), Some("r1:1:2")); + + // Page 3: last PP, page fills at the final object. + let page3 = list_report_objects( + &report_path, + "run_0001", + ObjectScope::All, + 2, + page2.next_cursor.as_deref(), + ) + .expect("page3"); + assert_eq!(page3.data.len(), 2); + assert_eq!(page3.next_cursor.as_deref(), Some("r1:2:2")); + + // Page 4: nothing left. + let page4 = list_report_objects( + &report_path, + "run_0001", + ObjectScope::All, + 2, + page3.next_cursor.as_deref(), + ) + .expect("page4"); + assert!(page4.data.is_empty()); + assert_eq!(page4.next_cursor, None); + + // A page larger than the remaining tail must not produce a cursor. + let tail = list_report_objects(&report_path, "run_0001", ObjectScope::All, 10, None) + .expect("tail"); + assert_eq!(tail.data.len(), 6); + assert_eq!(tail.next_cursor, None); + } + + #[test] + fn object_listing_scope_skips_non_matching_publication_points() { + let temp = tempfile::tempdir().expect("tempdir"); + let report_path = temp.path().join("report.json"); + write_multi_pp_report(&report_path); + + // PP scope: only PP 1 (node_id 2) objects, cursor resumes within PP 1. + let page1 = list_report_objects( + &report_path, + "run_0001", + ObjectScope::PublicationPoint("node_2".to_string()), + 1, + None, + ) + .expect("page1"); + assert_eq!(page1.data.len(), 1); + assert_eq!(page1.data[0].uri, "rsync://repo-b.example/rpki/m.mft"); + assert_eq!(page1.next_cursor.as_deref(), Some("r1:1:1")); + let page2 = list_report_objects( + &report_path, + "run_0001", + ObjectScope::PublicationPoint("node_2".to_string()), + 10, + page1.next_cursor.as_deref(), + ) + .expect("page2"); + assert_eq!(page2.data.len(), 1); + assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/b.roa"); + assert_eq!(page2.next_cursor, None); + + // Repo scope derived from the PP without rrdp uri (repo-b). + let probe_pp = list_report_objects( + &report_path, + "run_0001", + ObjectScope::PublicationPoint("node_2".to_string()), + 10, + None, + ) + .expect("probe"); + let repo_id = probe_pp.data[0].repo_id.clone(); + let repo_page = list_report_objects( + &report_path, + "run_0001", + ObjectScope::Repo(repo_id), + 10, + None, + ) + .expect("repo page"); + assert_eq!(repo_page.data.len(), 2); + assert!( + repo_page + .data + .iter() + .all(|object| object.uri.contains("repo-b.example")) + ); + + // Unknown scope id: empty page, no cursor, no error. + let empty = list_report_objects( + &report_path, + "run_0001", + ObjectScope::PublicationPoint("node_999".to_string()), + 10, + None, + ) + .expect("empty"); + assert!(empty.data.is_empty()); + assert_eq!(empty.next_cursor, None); + + // Filtered listing keeps working across skipped PPs. + let mut filter = ObjectFilter::default(); + filter.query = Some("c.roa".to_string()); + let filtered = list_report_objects_filtered( + &report_path, + "run_0001", + ObjectScope::All, + &filter, + 10, + None, + ) + .expect("filtered"); + assert_eq!(filtered.data.len(), 1); + assert_eq!(filtered.data[0].uri, "rsync://repo-c.example/rpki/c.roa"); + assert_eq!(filtered.next_cursor, None); + } } diff --git a/src/query_db.rs b/src/query_db.rs index eaaf3b8..bb059bb 100644 --- a/src/query_db.rs +++ b/src/query_db.rs @@ -60,6 +60,8 @@ pub enum QueryDbError { MissingColumnFamily(&'static str), #[error("invalid run artifact: {0}")] InvalidArtifact(String), + #[error("report data for run {0} is no longer available (retention cleanup)")] + RunDataGone(String), #[error("CIR decode error: {0}")] CirDecode(String), } @@ -289,6 +291,7 @@ impl QueryDb { opts.create_if_missing(true); opts.create_missing_column_families(true); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let descriptors = QUERY_DB_COLUMN_FAMILIES .iter() .map(|name| ColumnFamilyDescriptor::new(*name, cf_options())) @@ -310,6 +313,7 @@ impl QueryDb { opts.create_if_missing(false); opts.create_missing_column_families(false); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); let descriptors = QUERY_DB_COLUMN_FAMILIES .iter() .map(|name| ColumnFamilyDescriptor::new(*name, cf_options())) @@ -351,8 +355,10 @@ impl QueryDb { pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult> { if run_id == "latest" || run_id == "latest_run" { self.latest_ready_run() - } else { + } else if self.get_run(run_id)?.is_some() { Ok(Some(run_id.to_string())) + } else { + Ok(None) } } @@ -361,7 +367,38 @@ impl QueryDb { limit: usize, cursor: Option<&str>, ) -> QueryDbResult> { - self.list_json_by_prefix(CF_RUNS, "run/", limit, cursor) + // Runs are listed newest first: zero-padded run ids sort by age, so a + // reverse scan over the `run/` prefix yields the latest run on page 1. + // Cursor semantics mirror `list_json_by_prefix`: the cursor is the + // first key of the next page (inclusive). + let limit = limit.clamp(1, 1000); + let cf = self.cf(CF_RUNS)?; + let prefix = "run/"; + let start = match cursor { + Some(cursor) => cursor.as_bytes().to_vec(), + None => prefix_range_end(prefix.as_bytes()) + .unwrap_or_else(|| prefix.as_bytes().to_vec()), + }; + let mut data = Vec::new(); + let mut next_cursor = None; + let mode = IteratorMode::From(&start, rocksdb::Direction::Reverse); + for item in self.db.iterator_cf(cf, mode) { + let (key, value) = item?; + let key_str = String::from_utf8_lossy(&key); + if !key_str.starts_with(prefix) { + break; + } + if data.len() >= limit { + next_cursor = Some(key_str.to_string()); + break; + } + data.push(serde_json::from_slice(&value)?); + } + Ok(QueryPage { + data, + next_cursor, + limit, + }) } pub fn list_repos( @@ -691,9 +728,17 @@ impl QueryDb { } fn report_path_for_run(&self, run_id: &str) -> QueryDbResult> { - Ok(self - .get_run(run_id)? - .map(|run| Path::new(&run.run_dir).join("report.json"))) + let Some(run) = self.get_run(run_id)? else { + return Ok(None); + }; + let path = Path::new(&run.run_dir).join("report.json"); + if !path.exists() { + // The run is still indexed but its report.json was removed by + // retention cleanup; callers must be able to tell this apart + // from a genuine io failure (mapped to 404 upstream). + return Err(QueryDbError::RunDataGone(run_id.to_string())); + } + Ok(Some(path)) } pub fn get_object_projection( @@ -1094,6 +1139,7 @@ impl QueryDb { fn cf_options() -> Options { let mut opts = Options::default(); opts.set_compression_type(rocksdb::DBCompressionType::Lz4); + opts.set_max_open_files(512); opts } @@ -1951,15 +1997,17 @@ mod tests { db.resolve_run_id("run_0001").unwrap().as_deref(), Some("run_0001") ); + assert_eq!(db.resolve_run_id("run_9999").unwrap(), None); let first_page = db.list_runs(1, None).expect("runs"); assert_eq!(first_page.data.len(), 1); - assert_eq!(first_page.data[0].run_id, "run_0001"); + assert_eq!(first_page.data[0].run_id, "run_0002"); let second_page = db .list_runs(1, first_page.next_cursor.as_deref()) .expect("second runs"); assert_eq!(second_page.data.len(), 1); - assert_eq!(second_page.data[0].run_id, "run_0002"); + assert_eq!(second_page.data[0].run_id, "run_0001"); + assert!(second_page.next_cursor.is_none()); let repos = db.list_repos("run_0002", 10, None).expect("repos"); assert_eq!(repos.data.len(), 1); @@ -2105,6 +2153,43 @@ mod tests { ); } + #[test] + fn missing_report_file_is_reported_as_run_data_gone() { + let temp = tempfile::tempdir().expect("tempdir"); + let run1 = temp.path().join("runs/run_0001"); + fs::create_dir_all(&run1).expect("run1"); + write_sample_run(&run1, "run_0001", 1); + + let query_db_path = temp.path().join("query-db"); + index_artifacts(&ArtifactIndexerConfig { + query_db_path: query_db_path.clone(), + run_root: Some(temp.path().to_path_buf()), + run_dir: None, + repo_bytes_db_path: None, + projection_entry_limit: 50, + min_run_seq: None, + retain_indexed_runs: None, + }) + .expect("index"); + let db = QueryDb::open(&query_db_path).expect("open query db"); + assert_eq!(db.list_objects("run_0001", 10, None).unwrap().data.len(), 2); + + // Simulate retention cleanup removing the run directory while the + // index record survives: object listing/lookup must fail with a + // distinguishable error instead of a bare io error. + fs::remove_dir_all(&run1).expect("remove run dir"); + let err = db.list_objects("run_0001", 10, None).unwrap_err(); + assert!( + matches!(err, QueryDbError::RunDataGone(ref run_id) if run_id == "run_0001"), + "unexpected error: {err}" + ); + let err = db.get_object_by_sha256("run_0001", "22").unwrap_err(); + assert!( + matches!(err, QueryDbError::RunDataGone(_)), + "unexpected error: {err}" + ); + } + #[test] fn repeated_indexing_does_not_move_latest_backwards() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/ui/rpki-explorer/src/api/client.ts b/ui/rpki-explorer/src/api/client.ts index d3fa836..47ab972 100644 --- a/ui/rpki-explorer/src/api/client.ts +++ b/ui/rpki-explorer/src/api/client.ts @@ -51,11 +51,22 @@ async function parseErrorBody(res: Response): Promise { return `HTTP ${res.status} ${res.statusText}`.trim(); } +/** Default timeout for every API request — hung streams must not spin forever. */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** Combine the caller's abort signal (if any) with the default timeout. */ +function requestSignal(signal?: AbortSignal | null): AbortSignal { + const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter( + (s): s is AbortSignal => s != null, + ); + return AbortSignal.any(signals); +} + /** 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); + res = await fetch(path, { ...init, signal: requestSignal(init?.signal) }); } catch (err) { throw new ApiError(0, err instanceof Error ? err.message : "network error"); } @@ -69,7 +80,7 @@ export async function apiFetch(path: string, init?: RequestInit): Promise export async function apiFetchBlob(path: string): Promise { let res: Response; try { - res = await fetch(path); + res = await fetch(path, { signal: requestSignal() }); } catch (err) { throw new ApiError(0, err instanceof Error ? err.message : "network error"); } diff --git a/ui/rpki-explorer/src/api/schemas.ts b/ui/rpki-explorer/src/api/schemas.ts index 18c3348..07cb3ab 100644 --- a/ui/rpki-explorer/src/api/schemas.ts +++ b/ui/rpki-explorer/src/api/schemas.ts @@ -244,7 +244,8 @@ export const manifestFileEntrySchema = z export const revokedCertEntrySchema = z .object({ serialNumberHex: z.string().nullish(), - serialNumber: z.string().nullish(), + // Backend serializes CRL serials as a JSON number when they fit, else a string. + serialNumber: z.union([z.string(), z.number()]).nullish(), revocationDate: z.string().nullish(), }) .passthrough(); diff --git a/ui/rpki-explorer/src/api/service.ts b/ui/rpki-explorer/src/api/service.ts index ccf4305..9ea5fd8 100644 --- a/ui/rpki-explorer/src/api/service.ts +++ b/ui/rpki-explorer/src/api/service.ts @@ -76,7 +76,8 @@ async function getList( /* ------------------------------------------------------------------ */ export function getServiceInfo(): Promise { - return getData(`${API}`, serviceInfoSchema); + // Trailing slash: the axum router serves the info document at `/api/v1/`. + return getData(`${API}/`, serviceInfoSchema); } export function getHealth(): Promise { diff --git a/ui/rpki-explorer/src/components/DataTable.tsx b/ui/rpki-explorer/src/components/DataTable.tsx index b78c696..3a3b655 100644 --- a/ui/rpki-explorer/src/components/DataTable.tsx +++ b/ui/rpki-explorer/src/components/DataTable.tsx @@ -86,6 +86,19 @@ export function DataTable({ key={rowKey(row)} className={onRowClick ? "clickable" : undefined} onClick={onRowClick ? () => onRowClick(row) : undefined} + // Clickable rows behave like links: focusable and Enter/Space-activated. + tabIndex={onRowClick ? 0 : undefined} + role={onRowClick ? "link" : undefined} + onKeyDown={ + onRowClick + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onRowClick(row); + } + } + : undefined + } > {columns.map((col) => ( { + if (value) params.set(key, value); + else params.delete(key); + }; + write("type", next.type); + write("result", next.result); + write("reason", next.reason); + write("q", next.q); + if (next.rejectedOnly) params.set("rejected", "true"); + else params.delete("rejected"); + return params; +} + /** 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"]; @@ -55,7 +87,16 @@ export function ObjectsTable({ useEffect(() => { setQDraft(filters.q); }, [filters.q]); - const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]); + + // Auto-apply the URI draft after a short typing pause (Enter/blur still work). + useEffect(() => { + const trimmed = qDraft.trim(); + if (trimmed === filters.q) return; + const timer = setTimeout(() => onFiltersChange({ ...filters, q: trimmed }), 400); + return () => clearTimeout(timer); + }, [qDraft, filters, onFiltersChange]); + + const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.reason, filters.rejectedOnly, filters.q]); const pager = useCursorPager(filterKey); const query = useQuery({ @@ -67,6 +108,7 @@ export function ObjectsTable({ type: filters.type || undefined, result: filters.result || undefined, rejected: filters.rejectedOnly || undefined, + reason: filters.reason || undefined, q: filters.q || undefined, }), placeholderData: (prev) => prev, @@ -101,7 +143,9 @@ export function ObjectsTable({ { key: "result", header: "Result", - render: (obj) => , + // Raw `result` value — the Result filter options must match what the + // column displays (rejection is visible via the reason column/filter). + render: (obj) => , }, ...(showReason ? [ @@ -162,6 +206,17 @@ export function ObjectsTable({ ))} +
+ + onFiltersChange({ ...filters, reason: e.target.value })} + placeholder="substring…" + /> +
- server-side filters + server-side filters · URI search applies as you type diff --git a/ui/rpki-explorer/src/components/ProjectionView.tsx b/ui/rpki-explorer/src/components/ProjectionView.tsx index 0fa460b..106b170 100644 --- a/ui/rpki-explorer/src/components/ProjectionView.tsx +++ b/ui/rpki-explorer/src/components/ProjectionView.tsx @@ -182,12 +182,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan placeholderData: (prev) => prev, }); - const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [ + const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | number | null; revocationDate?: string | null }>[] = [ { key: "serial", header: "Serial number", render: (row) => ( - + ), }, { key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) }, diff --git a/ui/rpki-explorer/src/components/Shell.tsx b/ui/rpki-explorer/src/components/Shell.tsx index 3853988..51ef8cd 100644 --- a/ui/rpki-explorer/src/components/Shell.tsx +++ b/ui/rpki-explorer/src/components/Shell.tsx @@ -129,6 +129,9 @@ export function Shell({ children }: { children: ReactNode }) { return (
+ + Skip to content +
); } diff --git a/ui/rpki-explorer/src/components/Tabs.tsx b/ui/rpki-explorer/src/components/Tabs.tsx index a648945..74ce87c 100644 --- a/ui/rpki-explorer/src/components/Tabs.tsx +++ b/ui/rpki-explorer/src/components/Tabs.tsx @@ -49,7 +49,9 @@ export function Tabs({ role="tab" id={`tab-${tab.id}`} aria-selected={selected} - aria-controls={`tabpanel-${tab.id}`} + // Only the active tab's panel is mounted — pointing aria-controls + // at an unmounted panel would leave a dangling reference. + aria-controls={selected ? `tabpanel-${tab.id}` : undefined} tabIndex={selected ? 0 : -1} onClick={() => onChange(tab.id)} > diff --git a/ui/rpki-explorer/src/lib/cursor.ts b/ui/rpki-explorer/src/lib/cursor.ts index f567104..4acdf16 100644 --- a/ui/rpki-explorer/src/lib/cursor.ts +++ b/ui/rpki-explorer/src/lib/cursor.ts @@ -1,5 +1,6 @@ /** Cursor pagination state (previous-page stack) shared by all paged tables. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; export interface CursorPager { /** Cursor to pass to the backend for the current page (null = first page). */ @@ -40,33 +41,72 @@ export function pageNumber(state: PagerState): number { /** * Cursor pager that resets whenever `resetKey` changes (run id, filters…). + * + * The current cursor lives in the `?cursor=` URL param so a refresh or a + * shared link restores the current page; the previous-page stack stays in + * component state (Prev works within one mounted session). Only one pager + * is mounted per page, so a single shared param is unambiguous. */ export function useCursorPager(resetKey: string): CursorPager { - const [state, setState] = useState(INITIAL_PAGER_STATE); + const [searchParams, setSearchParams] = useSearchParams(); + const cursor = searchParams.get("cursor"); + const [stack, setStack] = useState<(string | null)[]>([]); + const writeCursor = useCallback( + (next: string | null) => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (next === null) params.delete("cursor"); + else params.set("cursor", next); + return params; + }, + { preventScrollReset: true }, + ); + }, + [setSearchParams], + ); + + // Reset on resetKey change — but not on first mount, so a refreshed or + // deep-linked `?cursor=` survives. + const lastResetKey = useRef(resetKey); useEffect(() => { - setState(INITIAL_PAGER_STATE); - }, [resetKey]); + if (lastResetKey.current === resetKey) return; + lastResetKey.current = resetKey; + setStack([]); + writeCursor(null); + }, [resetKey, writeCursor]); - const goNext = useCallback((nextCursor: string | null) => { - setState((s) => advancePager(s, nextCursor)); - }, []); + const goNext = useCallback( + (nextCursor: string | null) => { + if (!nextCursor) return; + setStack((s) => [...s, cursor]); + writeCursor(nextCursor); + }, + [cursor, writeCursor], + ); const goPrev = useCallback(() => { - setState((s) => retreatPager(s)); - }, []); + if (stack.length === 0) return; + const prev = stack[stack.length - 1]; + setStack(stack.slice(0, -1)); + writeCursor(prev); + }, [stack, writeCursor]); - const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []); + const reset = useCallback(() => { + setStack([]); + writeCursor(null); + }, [writeCursor]); return useMemo( () => ({ - cursor: state.cursor, - page: pageNumber(state), - canPrev: state.stack.length > 0, + cursor, + page: stack.length + 1, + canPrev: stack.length > 0, goNext, goPrev, reset, }), - [state, goNext, goPrev, reset], + [cursor, stack, goNext, goPrev, reset], ); } diff --git a/ui/rpki-explorer/src/lib/format.test.ts b/ui/rpki-explorer/src/lib/format.test.ts index 70e4009..cd9a50c 100644 --- a/ui/rpki-explorer/src/lib/format.test.ts +++ b/ui/rpki-explorer/src/lib/format.test.ts @@ -69,6 +69,14 @@ describe("formatPercent", () => { it("guards zero total", () => { expect(formatPercent(1, 0)).toBe("—"); }); + it("marks tiny non-zero shares instead of rounding to 0.0%", () => { + expect(formatPercent(1, 5000)).toBe("<0.1%"); + expect(formatPercent(0, 5000)).toBe("0.0%"); + }); + it("marks near-complete shares instead of rounding to 100.0%", () => { + expect(formatPercent(4999, 5000)).toBe(">99.9%"); + expect(formatPercent(5000, 5000)).toBe("100.0%"); + }); }); describe("truncateMiddle", () => { diff --git a/ui/rpki-explorer/src/lib/format.ts b/ui/rpki-explorer/src/lib/format.ts index 3eeec4b..ed80b44 100644 --- a/ui/rpki-explorer/src/lib/format.ts +++ b/ui/rpki-explorer/src/lib/format.ts @@ -70,7 +70,12 @@ export function formatRelative(value: string | null | undefined): string { export function formatPercent(part: number, total: number): string { if (!total) return "—"; - return `${((part / total) * 100).toFixed(1)}%`; + const pct = (part / total) * 100; + // One decimal is misleading at the extremes: a non-zero share rounding to + // "0.0%" looks empty, and 99.95% rounding to "100.0%" looks complete. + if (pct > 0 && pct < 0.1) return "<0.1%"; + if (pct < 100 && pct > 99.9) return ">99.9%"; + return `${pct.toFixed(1)}%`; } /** Truncate a long identifier for display, keeping head and tail. */ diff --git a/ui/rpki-explorer/src/lib/useExportJob.ts b/ui/rpki-explorer/src/lib/useExportJob.ts new file mode 100644 index 0000000..f9a8b66 --- /dev/null +++ b/ui/rpki-explorer/src/lib/useExportJob.ts @@ -0,0 +1,34 @@ +/** + * Shared "start export job + poll until finished" flow used by the object, + * repository and publication point detail pages. The page renders the + * returned mutation/query state with `WorkflowStatus` and friends. + */ +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createExport, getExportJob, type ExportRequest } from "../api/service"; + +export function useExportJob(runId: string, request: ExportRequest) { + const [jobId, setJobId] = useState(null); + + const startMutation = useMutation({ + mutationFn: () => createExport(runId, request), + onSuccess: (job) => setJobId(job.jobId), + }); + + const jobQuery = useQuery({ + queryKey: ["export-job", runId, jobId], + queryFn: () => getExportJob(runId, jobId!), + enabled: jobId !== null, + refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false), + }); + + // Keep the trigger disabled until the job reaches a terminal state so the + // same export cannot be double-submitted while it is still running. + const running = + startMutation.isPending || + (jobId !== null && + jobQuery.data?.status !== "complete" && + jobQuery.data?.status !== "failed"); + + return { startMutation, jobQuery, running }; +} diff --git a/ui/rpki-explorer/src/lib/useRun.ts b/ui/rpki-explorer/src/lib/useRun.ts index afa6017..5e0b8a9 100644 --- a/ui/rpki-explorer/src/lib/useRun.ts +++ b/ui/rpki-explorer/src/lib/useRun.ts @@ -1,8 +1,9 @@ /** Resolve the active run record for the current `?run=` context. */ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiError } from "../api/client"; import { getRun } from "../api/service"; import type { RunRecord } from "../api/schemas"; -import { useRunId } from "./run"; +import { LATEST_RUN, useRunId } from "./run"; export function useRun(): { runId: string; runQuery: UseQueryResult } { const runId = useRunId(); @@ -13,3 +14,16 @@ export function useRun(): { runId: string; runQuery: UseQueryResult } }); return { runId, runQuery }; } + +/** + * Run-aware title for the run-scope error gate: a 404 means the requested + * run does not exist; anything else is a service/transport failure. + */ +export function runErrorTitle(runId: string, error: unknown): string { + if (error instanceof ApiError && error.status === 404) { + return runId === LATEST_RUN + ? "No indexed run available yet" + : `Run ${runId} does not exist`; + } + return `Failed to load run ${runId}`; +} diff --git a/ui/rpki-explorer/src/lib/vrpQuery.test.ts b/ui/rpki-explorer/src/lib/vrpQuery.test.ts index 42f6570..c32c97c 100644 --- a/ui/rpki-explorer/src/lib/vrpQuery.test.ts +++ b/ui/rpki-explorer/src/lib/vrpQuery.test.ts @@ -94,4 +94,34 @@ describe("classifyNetworkQuery", () => { expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" }); expect(classifyNetworkQuery("")).toEqual({ kind: "none" }); }); + + it("reads two tokens as network + ASN in either order", () => { + expect(classifyNetworkQuery("1.0.0.1 AS13335")).toEqual({ + kind: "ip", + ip: "1.0.0.1", + asn: 13335, + }); + expect(classifyNetworkQuery("AS13335 1.0.0.1")).toEqual({ + kind: "ip", + ip: "1.0.0.1", + asn: 13335, + }); + expect(classifyNetworkQuery("192.0.2.0/24 13335")).toEqual({ + kind: "prefix", + prefix: "192.0.2.0/24", + asn: 13335, + }); + expect(classifyNetworkQuery(" 2001:db8::/32 as64496 ")).toEqual({ + kind: "prefix", + prefix: "2001:db8::/32", + asn: 64496, + }); + }); + + it("rejects multi-token queries without a network+ASN pair", () => { + expect(classifyNetworkQuery("13335 64496")).toEqual({ kind: "none" }); + expect(classifyNetworkQuery("1.0.0.1 192.0.2.1")).toEqual({ kind: "none" }); + expect(classifyNetworkQuery("1.0.0.1 AS13335 extra")).toEqual({ kind: "none" }); + expect(classifyNetworkQuery("foo bar")).toEqual({ kind: "none" }); + }); }); diff --git a/ui/rpki-explorer/src/lib/vrpQuery.ts b/ui/rpki-explorer/src/lib/vrpQuery.ts index 09f0d79..c462335 100644 --- a/ui/rpki-explorer/src/lib/vrpQuery.ts +++ b/ui/rpki-explorer/src/lib/vrpQuery.ts @@ -7,8 +7,8 @@ */ export type NetworkQuery = - | { kind: "ip"; ip: string } - | { kind: "prefix"; prefix: string } + | { kind: "ip"; ip: string; asn?: number } + | { kind: "prefix"; prefix: string; asn?: number } | { kind: "asn"; asn: number } | { kind: "none" }; @@ -90,10 +90,30 @@ export function parsePrefix(input: string): string | null { * Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none. * ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so * no overlap. A bare number is always treated as an ASN.) + * + * Two whitespace-separated tokens are read as a combined query — one network + * token (IP or prefix) plus one ASN token (`AS13335` or `13335`), in either + * order — yielding an ip/prefix query carrying an `asn` filter. */ export function classifyNetworkQuery(input: string): NetworkQuery { const s = input.trim(); if (!s) return { kind: "none" }; + const tokens = s.split(/\s+/); + if (tokens.length === 2) { + for (const [netToken, asnToken] of [ + [tokens[0], tokens[1]], + [tokens[1], tokens[0]], + ] as const) { + const asn = parseAsn(asnToken); + if (asn === null) continue; + const prefix = parsePrefix(netToken); + if (prefix) return { kind: "prefix", prefix, asn }; + const ip = parseIpAddress(netToken); + if (ip) return { kind: "ip", ip, asn }; + } + return { kind: "none" }; + } + if (tokens.length > 2) return { kind: "none" }; const prefix = parsePrefix(s); if (prefix) return { kind: "prefix", prefix }; const ip = parseIpAddress(s); diff --git a/ui/rpki-explorer/src/pages/ApiStatusPage.tsx b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx index e168102..69cd79a 100644 --- a/ui/rpki-explorer/src/pages/ApiStatusPage.tsx +++ b/ui/rpki-explorer/src/pages/ApiStatusPage.tsx @@ -80,7 +80,6 @@ export default function ApiStatusPage() {
  • 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.
  • diff --git a/ui/rpki-explorer/src/pages/ExportsPage.tsx b/ui/rpki-explorer/src/pages/ExportsPage.tsx index bfd67fe..bcb0196 100644 --- a/ui/rpki-explorer/src/pages/ExportsPage.tsx +++ b/ui/rpki-explorer/src/pages/ExportsPage.tsx @@ -5,16 +5,16 @@ 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 { ErrorBlock, Notice } from "../components/StateBlock"; import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format"; -import { useRun } from "../lib/useRun"; +import { useRun, runErrorTitle } 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 { runId, runQuery } = useRun(); const exportsQuery = useQuery({ queryKey: ["exports", runId], @@ -25,6 +25,19 @@ export default function ExportsPage() { }, }); + if (runQuery.isError) { + return ( +
    + + runQuery.refetch()} + title={runErrorTitle(runId, runQuery.error)} + /> +
    + ); + } + const columns: Column[] = [ { key: "job", diff --git a/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx index a4c4dd2..6db169f 100644 --- a/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx +++ b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx @@ -4,9 +4,7 @@ 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, @@ -23,8 +21,9 @@ 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 { formatInt, objectTypeLabel } from "../lib/format"; import { withRunParam } from "../lib/run"; +import { useExportJob } from "../lib/useExportJob"; import { useRun } from "../lib/useRun"; function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string { @@ -207,7 +206,6 @@ export default function ObjectDetailPage() { 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], @@ -229,17 +227,11 @@ export default function ObjectDetailPage() { }, }); - 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), - }); + const { + startMutation: exportMutation, + jobQuery: exportJobQuery, + running: exportRunning, + } = useExportJob(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }); if (objectQuery.isError) { return ( @@ -298,7 +290,7 @@ export default function ObjectDetailPage() { type="button" className="btn" onClick={() => exportMutation.mutate()} - disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"} + disabled={exportRunning} > {exportMutation.isPending ?
    ); @@ -170,6 +170,17 @@ export default function OverviewPage() { numeric: true, render: (repo) => formatDurationMs(repo.syncDurationMsTotal), }, + { + key: "terminal", + header: "Terminal states", + render: (repo) => ( + + {Object.entries(repo.terminalStates ?? {}).map(([state, count]) => ( + + ))} + + ), + }, ]; return ( @@ -224,6 +235,7 @@ export default function OverviewPage() { value={formatInt(counts?.warnings)} tone="amber" sub="objects with warnings" + to={withRunParam("/validation", runId)} /> @@ -238,7 +250,7 @@ export default function OverviewPage() { ) : ( <>
    - + No object type stats recorded.

    ) : (
    - + (EMPTY_OBJECT_FILTERS); + const [searchParams, setSearchParams] = useSearchParams(); + // Object filters live in the URL so they survive reloads and stay shareable. + const objectFilters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]); + + const setObjectFilters = (next: ObjectFilterValues) => { + setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true }); + }; const ppQuery = useQuery({ queryKey: ["pp", runId, ppId], queryFn: () => getPublicationPoint(runId, ppId), }); + const exportJob = useExportJob(runId, { scope: "publication_point", ppId }); if (ppQuery.isError) { return ( @@ -60,9 +71,31 @@ export default function PublicationPointDetailPage() { {pp.manifestRsyncUri ?? pp.ppId}} subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined} - actions={} + actions={ + <> + + + + } /> + {exportJob.startMutation.isError ? ( + Export failed to start: {exportJob.startMutation.error.message} + ) : null} + {exportJob.jobQuery.data ? : null} +
    diff --git a/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx b/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx index bf7e54f..1a79dc3 100644 --- a/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx +++ b/ui/rpki-explorer/src/pages/PublicationPointsPage.tsx @@ -104,7 +104,8 @@ export default function PublicationPointsPage() { loading={ppsQuery.isPending} error={ppsQuery.isError ? ppsQuery.error : undefined} onRetry={() => ppsQuery.refetch()} - emptyTitle="No publication points indexed" + emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No publication points indexed"} + emptyHint={needle.trim() ? "The filter only narrows the rows of the current page." : undefined} caption="Publication points" onRowClick={(pp) => navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId)) diff --git a/ui/rpki-explorer/src/pages/RepositoriesPage.tsx b/ui/rpki-explorer/src/pages/RepositoriesPage.tsx index 5a84324..b7cd9ea 100644 --- a/ui/rpki-explorer/src/pages/RepositoriesPage.tsx +++ b/ui/rpki-explorer/src/pages/RepositoriesPage.tsx @@ -122,8 +122,12 @@ export default function RepositoriesPage() { 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." + emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No repositories indexed"} + emptyHint={ + needle.trim() + ? "The filter only narrows the rows of the current page." + : "The query service has not indexed any run yet." + } caption="Repositories" onRowClick={(repo) => navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId)) diff --git a/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx b/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx index 81b6e11..b90b782 100644 --- a/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx +++ b/ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useMemo } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; +import { PackageOpen, RefreshCw } from "lucide-react"; import { getRepo, getRepoStats, @@ -13,7 +14,8 @@ import { CursorPagerControls } from "../components/CursorPagerControls"; import { DataTable, type Column } from "../components/DataTable"; import { KpiCard } from "../components/KpiCard"; import { - EMPTY_OBJECT_FILTERS, + objectFiltersFromParams, + objectFiltersToParams, ObjectsTable, type ObjectFilterValues, } from "../components/ObjectsTable"; @@ -21,10 +23,12 @@ 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 { WorkflowStatus } from "../components/WorkflowStatus"; +import { ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock"; import { useCursorPager } from "../lib/cursor"; import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format"; import { withRunParam } from "../lib/run"; +import { useExportJob } from "../lib/useExportJob"; import { useRun } from "../lib/useRun"; function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) { @@ -109,7 +113,12 @@ export default function RepositoryDetailPage() { const { runId, runQuery } = useRun(); const [searchParams, setSearchParams] = useSearchParams(); const tab = searchParams.get("tab") ?? "pps"; - const [objectFilters, setObjectFilters] = useState(EMPTY_OBJECT_FILTERS); + // Object filters live in the URL so they survive reloads and stay shareable. + const objectFilters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]); + + const setObjectFilters = (next: ObjectFilterValues) => { + setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true }); + }; const repoQuery = useQuery({ queryKey: ["repo", runId, repoId], @@ -119,6 +128,7 @@ export default function RepositoryDetailPage() { queryKey: ["repo-stats", runId, repoId], queryFn: () => getRepoStats(runId, repoId), }); + const exportJob = useExportJob(runId, { scope: "repo", repoId }); if (repoQuery.isError) { return ( @@ -157,9 +167,31 @@ export default function RepositoryDetailPage() { } - actions={} + actions={ + <> + + + + } /> + {exportJob.startMutation.isError ? ( + Export failed to start: {exportJob.startMutation.error.message} + ) : null} + {exportJob.jobQuery.data ? : null} +
    diff --git a/ui/rpki-explorer/src/pages/RunsPage.tsx b/ui/rpki-explorer/src/pages/RunsPage.tsx index 9801efb..5c46704 100644 --- a/ui/rpki-explorer/src/pages/RunsPage.tsx +++ b/ui/rpki-explorer/src/pages/RunsPage.tsx @@ -95,6 +95,7 @@ export default function RunsPage() { className="btn small" disabled={active} onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)} + aria-label={active ? `Run ${run.runId} is active` : `Use run ${run.runId}`} > {active ? "Active" : "Use run"} @@ -103,6 +104,7 @@ export default function RunsPage() { className="btn small" onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))} aria-expanded={expandedRun === run.runId} + aria-label={`Show artifacts for run ${run.runId}`} > Artifacts diff --git a/ui/rpki-explorer/src/pages/SearchPage.tsx b/ui/rpki-explorer/src/pages/SearchPage.tsx index fd56b4e..bb0b0e6 100644 --- a/ui/rpki-explorer/src/pages/SearchPage.tsx +++ b/ui/rpki-explorer/src/pages/SearchPage.tsx @@ -13,7 +13,7 @@ import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock"; import { useCursorPager } from "../lib/cursor"; import { objectTypeLabel } from "../lib/format"; import { withRunParam } from "../lib/run"; -import { useRun } from "../lib/useRun"; +import { useRun, runErrorTitle } from "../lib/useRun"; import { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery"; const VRP_PAGE_SIZE = 50; @@ -139,7 +139,7 @@ function VrpResults({ network }: { network: NetworkQuery & { kind: "ip" | "prefi } export default function SearchPage() { - const { runId } = useRun(); + const { runId, runQuery } = useRun(); const [searchParams, setSearchParams] = useSearchParams(); const q = searchParams.get("q") ?? ""; const [draft, setDraft] = useState(q); @@ -149,6 +149,18 @@ export default function SearchPage() { const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]); const isVrpLookup = network.kind === "ip" || network.kind === "prefix"; + // A combined query ("1.0.0.1 AS13335") carries its own ASN — prefill the + // ?asn= filter so the VRP results apply it (the user can still override). + useEffect(() => { + if (isVrpLookup && network.asn !== undefined) { + if (searchParams.get("asn") !== String(network.asn)) { + const params = new URLSearchParams(searchParams); + params.set("asn", String(network.asn)); + setSearchParams(params, { replace: true, preventScrollReset: true }); + } + } + }, [isVrpLookup, network, searchParams, setSearchParams]); + const searchQuery = useQuery({ queryKey: ["search", runId, q], queryFn: () => searchRun(runId, q, 10), @@ -164,6 +176,19 @@ export default function SearchPage() { setSearchParams(params, { preventScrollReset: true }); }; + if (runQuery.isError) { + return ( +
    + + runQuery.refetch()} + title={runErrorTitle(runId, runQuery.error)} + /> +
    + ); + } + const result = searchQuery.data; const totalHits = (result?.objects.length ?? 0) + @@ -287,6 +312,7 @@ export default function SearchPage() {
  • rsync:// / https:// URI — exact object match
  • 64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan
  • IPv4 / IPv6 address or CIDR prefix — VRP lookup (covering VRPs), with optional ASN filter
  • +
  • IP / prefix + ASN in one query (1.0.0.1 AS13335) — prefills the VRP ASN filter
  • AS number alone — not supported by the VRP API; combine with an IP or prefix
  • any other text — substring match on repository host/URI and publication point URIs
  • diff --git a/ui/rpki-explorer/src/pages/ValidationPage.tsx b/ui/rpki-explorer/src/pages/ValidationPage.tsx index e75dc3b..c4880c0 100644 --- a/ui/rpki-explorer/src/pages/ValidationPage.tsx +++ b/ui/rpki-explorer/src/pages/ValidationPage.tsx @@ -19,11 +19,11 @@ 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 { useRun, runErrorTitle } from "../lib/useRun"; import { useNavigate } from "react-router-dom"; export default function ValidationPage() { - const { runId } = useRun(); + const { runId, runQuery } = useRun(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const reason = searchParams.get("reason") ?? ""; @@ -88,6 +88,19 @@ export default function ValidationPage() { setSearchParams(params, { preventScrollReset: true }); }; + if (runQuery.isError) { + return ( +
    + + runQuery.refetch()} + title={runErrorTitle(runId, runQuery.error)} + /> +
    + ); + } + const columns: Column[] = [ { key: "type", diff --git a/ui/rpki-explorer/src/styles/components.css b/ui/rpki-explorer/src/styles/components.css index 7fd471d..2327f9d 100644 --- a/ui/rpki-explorer/src/styles/components.css +++ b/ui/rpki-explorer/src/styles/components.css @@ -149,6 +149,19 @@ .table-wrap { overflow-x: auto; border-radius: 0 0 var(--radius-l) var(--radius-l); + /* Scroll shadows hint at horizontally clipped columns (narrow viewports). */ + background: + linear-gradient(to right, var(--bg-1) 30%, transparent), + linear-gradient(to left, var(--bg-1) 30%, transparent), + radial-gradient(farthest-side at 0 50%, rgba(15, 23, 42, 0.14), transparent), + radial-gradient(farthest-side at 100% 50%, rgba(15, 23, 42, 0.14), transparent); + background-repeat: no-repeat; + background-size: + 40px 100%, + 40px 100%, + 12px 100%, + 12px 100%; + background-attachment: local, local, scroll, scroll; } table.data-table { diff --git a/ui/rpki-explorer/src/styles/shell.css b/ui/rpki-explorer/src/styles/shell.css index 0848c76..1b88976 100644 --- a/ui/rpki-explorer/src/styles/shell.css +++ b/ui/rpki-explorer/src/styles/shell.css @@ -13,6 +13,36 @@ grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr); } +/* Keyboard shortcut into the main content — hidden until focused. */ +.skip-link { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link:focus-visible { + width: auto; + height: auto; + padding: var(--space-2) var(--space-4); + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; + top: var(--space-3); + left: var(--space-3); + z-index: 100; + background: var(--blue-600); + color: #fff; + border-radius: var(--radius-m); + font-size: 13px; +} + .shell-brand { grid-area: brand; display: flex; @@ -294,6 +324,19 @@ align-items: center; overflow-x: auto; padding: var(--space-2); + /* Edge fades hint that the nav scrolls horizontally. */ + background: + linear-gradient(to right, var(--text-1) 30%, transparent), + linear-gradient(to left, var(--text-1) 30%, transparent), + radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.4), transparent), + radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.4), transparent); + background-repeat: no-repeat; + background-size: + 32px 100%, + 32px 100%, + 10px 100%, + 10px 100%; + background-attachment: local, local, scroll, scroll; } .shell-nav .nav-toggle, diff --git a/ui/rpki-explorer/src/styles/tokens.css b/ui/rpki-explorer/src/styles/tokens.css index 34f5eba..a1bb2b5 100644 --- a/ui/rpki-explorer/src/styles/tokens.css +++ b/ui/rpki-explorer/src/styles/tokens.css @@ -9,7 +9,7 @@ --text-1: #0f172a; --text-2: #475569; - --text-3: #8a97a8; + --text-3: #64748b; --bg-0: #f4f6fa; --bg-1: #ffffff;