20260726 修复rpki-explorer体验问题32项(#130):RocksDB max_open_files+compose ulimits、objects列表流式早停+scope前置(每页2.85s->10ms级)、未知/过期run统一404、runs倒序、导出文件名Content-Disposition、路径段percent_decode、nginx /api/v1代理与400回SPA;前端serialNumber schema、30s请求超时、reason筛选、组合VRP查询、导出反馈与repo/PP入口、键盘可达、cursor进URL、分页/对比度/skip-link等体验项
This commit is contained in:
parent
001f13209f
commit
cf68abf642
@ -15,6 +15,12 @@ services:
|
|||||||
GIT_REV: ${GIT_REV:-unknown}
|
GIT_REV: ${GIT_REV:-unknown}
|
||||||
container_name: rpki-explorer
|
container_name: rpki-explorer
|
||||||
restart: unless-stopped
|
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:
|
ports:
|
||||||
# Loopback only: access via SSH port forwarding, e.g.
|
# Loopback only: access via SSH port forwarding, e.g.
|
||||||
# ssh -L 9517:127.0.0.1:9517 root@<host>
|
# ssh -L 9517:127.0.0.1:9517 root@<host>
|
||||||
|
|||||||
@ -8,6 +8,27 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# query service has no CORS headers and no authentication: the API must be
|
||||||
# served same-origin with the UI.
|
# served same-origin with the UI.
|
||||||
location /api/v1/ {
|
location /api/v1/ {
|
||||||
|
|||||||
@ -542,7 +542,18 @@ impl ApiError {
|
|||||||
|
|
||||||
impl From<QueryDbError> for ApiError {
|
impl From<QueryDbError> for ApiError {
|
||||||
fn from(value: QueryDbError) -> Self {
|
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 (path, query) = split_target(target);
|
||||||
let query = parse_query(query);
|
let query = parse_query(query);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if segments.is_empty() {
|
if segments.is_empty() {
|
||||||
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
||||||
@ -1002,11 +1012,10 @@ fn route_post_request(
|
|||||||
) -> Result<Value, ApiError> {
|
) -> Result<Value, ApiError> {
|
||||||
let (path, _) = split_target(target);
|
let (path, _) = split_target(target);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["runs", raw_run_id, "exports"] => {
|
["runs", raw_run_id, "exports"] => {
|
||||||
@ -1159,11 +1168,10 @@ fn route_raw_request(
|
|||||||
) -> Option<Result<Vec<u8>, ApiError>> {
|
) -> Option<Result<Vec<u8>, ApiError>> {
|
||||||
let (path, _) = split_target(target);
|
let (path, _) = split_target(target);
|
||||||
let path = path.trim_end_matches('/');
|
let path = path.trim_end_matches('/');
|
||||||
let segments = path
|
let decoded_segments = decode_path_segments(path);
|
||||||
.trim_start_matches("/api/v1")
|
let segments = decoded_segments
|
||||||
.trim_start_matches('/')
|
.iter()
|
||||||
.split('/')
|
.map(String::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
match segments.as_slice() {
|
match segments.as_slice() {
|
||||||
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
|
["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response(
|
||||||
@ -1224,7 +1232,20 @@ fn export_download_response(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::new(404, "export output path not found"))?;
|
.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()))?;
|
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 {
|
fn export_job_store_key(run_id: &str, job_id: &str) -> String {
|
||||||
@ -2098,6 +2119,18 @@ fn hex_value(byte: u8) -> Option<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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<String, String>) -> usize {
|
fn limit(query: &BTreeMap<String, String>) -> usize {
|
||||||
query
|
query
|
||||||
.get("limit")
|
.get("limit")
|
||||||
@ -2248,13 +2281,26 @@ fn json_response(status: u16, value: &Value) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
||||||
|
binary_response_with_headers(status, content_type, &[], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_response_with_headers(
|
||||||
|
status: u16,
|
||||||
|
content_type: &str,
|
||||||
|
extra_headers: &[(String, String)],
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> Vec<u8> {
|
||||||
let reason = match status {
|
let reason = match status {
|
||||||
200 => "OK",
|
200 => "OK",
|
||||||
404 => "Not Found",
|
404 => "Not Found",
|
||||||
_ => "Internal Server Error",
|
_ => "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!(
|
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()
|
body.len()
|
||||||
)
|
)
|
||||||
.into_bytes();
|
.into_bytes();
|
||||||
@ -2262,6 +2308,15 @@ fn binary_response(status: u16, content_type: &str, body: Vec<u8>) -> Vec<u8> {
|
|||||||
response
|
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 {
|
fn is_sha256_hex(value: &str) -> bool {
|
||||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
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]
|
#[test]
|
||||||
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
|
fn raw_route_downloads_object_bytes_from_repo_bytes_db() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
@ -2813,6 +2956,31 @@ mod tests {
|
|||||||
assert_eq!(job.status, "complete");
|
assert_eq!(job.status, "complete");
|
||||||
assert_eq!(job.object_count, 1);
|
assert_eq!(job.object_count, 1);
|
||||||
assert!(job.bytes_written > 0);
|
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]
|
#[test]
|
||||||
|
|||||||
@ -90,6 +90,7 @@ impl ExternalRawStoreDb {
|
|||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
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()))?;
|
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
@ -187,6 +188,7 @@ impl ExternalRepoBytesDb {
|
|||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
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()))?;
|
let db = DB::open(&opts, &path).map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
@ -200,6 +202,7 @@ impl ExternalRepoBytesDb {
|
|||||||
let path = path.into();
|
let path = path.into();
|
||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let db = DB::open_for_read_only(&opts, &path, false)
|
let db = DB::open_for_read_only(&opts, &path, false)
|
||||||
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@ -227,6 +230,7 @@ impl ExternalRepoBytesDb {
|
|||||||
}
|
}
|
||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let db = DB::open_as_secondary(&opts, &path, &secondary_path)
|
let db = DB::open_as_secondary(&opts, &path, &secondary_path)
|
||||||
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@ -176,9 +176,14 @@ pub fn list_report_objects_filtered(
|
|||||||
start_pp_index,
|
start_pp_index,
|
||||||
start_object_index,
|
start_object_index,
|
||||||
);
|
);
|
||||||
ObjectScanSeed { state: &mut state }
|
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
|
||||||
.deserialize(&mut deserializer)
|
match scan {
|
||||||
.map_err(QueryDbError::from)?;
|
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 {
|
Ok(QueryPage {
|
||||||
data: state.data,
|
data: state.data,
|
||||||
next_cursor: state.next_cursor,
|
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}")
|
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)> {
|
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
|
||||||
let Some(cursor) = cursor else {
|
let Some(cursor) = cursor else {
|
||||||
return Ok((0, 0));
|
return Ok((0, 0));
|
||||||
@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
|
|||||||
scanned_objects: 0,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
|
|||||||
A: SeqAccess<'de>,
|
A: SeqAccess<'de>,
|
||||||
{
|
{
|
||||||
let mut pp_index = 0u64;
|
let mut pp_index = 0u64;
|
||||||
while let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? {
|
loop {
|
||||||
|
if self.state.list_page_complete() {
|
||||||
|
return Err(de::Error::custom(OBJECT_SCAN_COMPLETE_MSG));
|
||||||
|
}
|
||||||
|
let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
process_raw_publication_point(self.state, pp_index, raw_pp.get())
|
process_raw_publication_point(self.state, pp_index, raw_pp.get())
|
||||||
.map_err(de::Error::custom)?;
|
.map_err(de::Error::custom)?;
|
||||||
pp_index += 1;
|
pp_index += 1;
|
||||||
@ -822,6 +852,20 @@ fn process_raw_publication_point(
|
|||||||
raw_pp: &str,
|
raw_pp: &str,
|
||||||
) -> QueryDbResult<()> {
|
) -> QueryDbResult<()> {
|
||||||
state.scanned_publication_points += 1;
|
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 pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
|
||||||
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
||||||
if !state.scope.matches(&ctx) {
|
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<u64>,
|
||||||
|
rsync_base_uri: Option<String>,
|
||||||
|
manifest_rsync_uri: Option<String>,
|
||||||
|
publication_point_rsync_uri: Option<String>,
|
||||||
|
rrdp_notification_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
struct ReportObject {
|
struct ReportObject {
|
||||||
uri: String,
|
uri: String,
|
||||||
@ -1360,4 +1428,195 @@ mod tests {
|
|||||||
assert_eq!(found.object.result, "error");
|
assert_eq!(found.object.result, "error");
|
||||||
assert_eq!(found.resolution.mode, "report_scan");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,8 @@ pub enum QueryDbError {
|
|||||||
MissingColumnFamily(&'static str),
|
MissingColumnFamily(&'static str),
|
||||||
#[error("invalid run artifact: {0}")]
|
#[error("invalid run artifact: {0}")]
|
||||||
InvalidArtifact(String),
|
InvalidArtifact(String),
|
||||||
|
#[error("report data for run {0} is no longer available (retention cleanup)")]
|
||||||
|
RunDataGone(String),
|
||||||
#[error("CIR decode error: {0}")]
|
#[error("CIR decode error: {0}")]
|
||||||
CirDecode(String),
|
CirDecode(String),
|
||||||
}
|
}
|
||||||
@ -289,6 +291,7 @@ impl QueryDb {
|
|||||||
opts.create_if_missing(true);
|
opts.create_if_missing(true);
|
||||||
opts.create_missing_column_families(true);
|
opts.create_missing_column_families(true);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||||
@ -310,6 +313,7 @@ impl QueryDb {
|
|||||||
opts.create_if_missing(false);
|
opts.create_if_missing(false);
|
||||||
opts.create_missing_column_families(false);
|
opts.create_missing_column_families(false);
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||||
@ -351,8 +355,10 @@ impl QueryDb {
|
|||||||
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
||||||
if run_id == "latest" || run_id == "latest_run" {
|
if run_id == "latest" || run_id == "latest_run" {
|
||||||
self.latest_ready_run()
|
self.latest_ready_run()
|
||||||
} else {
|
} else if self.get_run(run_id)?.is_some() {
|
||||||
Ok(Some(run_id.to_string()))
|
Ok(Some(run_id.to_string()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,7 +367,38 @@ impl QueryDb {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
) -> QueryDbResult<QueryPage<RunRecord>> {
|
) -> QueryDbResult<QueryPage<RunRecord>> {
|
||||||
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(
|
pub fn list_repos(
|
||||||
@ -691,9 +728,17 @@ impl QueryDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
||||||
Ok(self
|
let Some(run) = self.get_run(run_id)? else {
|
||||||
.get_run(run_id)?
|
return Ok(None);
|
||||||
.map(|run| Path::new(&run.run_dir).join("report.json")))
|
};
|
||||||
|
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(
|
pub fn get_object_projection(
|
||||||
@ -1094,6 +1139,7 @@ impl QueryDb {
|
|||||||
fn cf_options() -> Options {
|
fn cf_options() -> Options {
|
||||||
let mut opts = Options::default();
|
let mut opts = Options::default();
|
||||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||||
|
opts.set_max_open_files(512);
|
||||||
opts
|
opts
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1951,15 +1997,17 @@ mod tests {
|
|||||||
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
||||||
Some("run_0001")
|
Some("run_0001")
|
||||||
);
|
);
|
||||||
|
assert_eq!(db.resolve_run_id("run_9999").unwrap(), None);
|
||||||
|
|
||||||
let first_page = db.list_runs(1, None).expect("runs");
|
let first_page = db.list_runs(1, None).expect("runs");
|
||||||
assert_eq!(first_page.data.len(), 1);
|
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
|
let second_page = db
|
||||||
.list_runs(1, first_page.next_cursor.as_deref())
|
.list_runs(1, first_page.next_cursor.as_deref())
|
||||||
.expect("second runs");
|
.expect("second runs");
|
||||||
assert_eq!(second_page.data.len(), 1);
|
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");
|
let repos = db.list_repos("run_0002", 10, None).expect("repos");
|
||||||
assert_eq!(repos.data.len(), 1);
|
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]
|
#[test]
|
||||||
fn repeated_indexing_does_not_move_latest_backwards() {
|
fn repeated_indexing_does_not_move_latest_backwards() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@ -51,11 +51,22 @@ async function parseErrorBody(res: Response): Promise<string> {
|
|||||||
return `HTTP ${res.status} ${res.statusText}`.trim();
|
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. */
|
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
||||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(path, init);
|
res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
}
|
}
|
||||||
@ -69,7 +80,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
|||||||
export async function apiFetchBlob(path: string): Promise<Blob> {
|
export async function apiFetchBlob(path: string): Promise<Blob> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(path);
|
res = await fetch(path, { signal: requestSignal() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -244,7 +244,8 @@ export const manifestFileEntrySchema = z
|
|||||||
export const revokedCertEntrySchema = z
|
export const revokedCertEntrySchema = z
|
||||||
.object({
|
.object({
|
||||||
serialNumberHex: z.string().nullish(),
|
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(),
|
revocationDate: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
@ -76,7 +76,8 @@ async function getList<S extends z.ZodTypeAny>(
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function getServiceInfo(): Promise<ServiceInfo> {
|
export function getServiceInfo(): Promise<ServiceInfo> {
|
||||||
return getData(`${API}`, serviceInfoSchema);
|
// Trailing slash: the axum router serves the info document at `/api/v1/`.
|
||||||
|
return getData(`${API}/`, serviceInfoSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHealth(): Promise<HealthStatus> {
|
export function getHealth(): Promise<HealthStatus> {
|
||||||
|
|||||||
@ -86,6 +86,19 @@ export function DataTable<T>({
|
|||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
className={onRowClick ? "clickable" : undefined}
|
className={onRowClick ? "clickable" : undefined}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : 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) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
|
|||||||
@ -14,16 +14,48 @@ import { StatusPill } from "./StatusPill";
|
|||||||
export interface ObjectFilterValues {
|
export interface ObjectFilterValues {
|
||||||
type: string;
|
type: string;
|
||||||
result: string;
|
result: string;
|
||||||
|
reason: string;
|
||||||
rejectedOnly: boolean;
|
rejectedOnly: boolean;
|
||||||
q: string;
|
q: string;
|
||||||
}
|
}
|
||||||
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
||||||
type: "",
|
type: "",
|
||||||
result: "",
|
result: "",
|
||||||
|
reason: "",
|
||||||
rejectedOnly: false,
|
rejectedOnly: false,
|
||||||
q: "",
|
q: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Read object filters from the page URL (standalone pages sync them there). */
|
||||||
|
export function objectFiltersFromParams(params: URLSearchParams): ObjectFilterValues {
|
||||||
|
return {
|
||||||
|
type: params.get("type") ?? "",
|
||||||
|
result: params.get("result") ?? "",
|
||||||
|
reason: params.get("reason") ?? "",
|
||||||
|
rejectedOnly: params.get("rejected") === "true",
|
||||||
|
q: params.get("q") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write object filters into a URLSearchParams copy, preserving other params. */
|
||||||
|
export function objectFiltersToParams(
|
||||||
|
next: ObjectFilterValues,
|
||||||
|
base: URLSearchParams,
|
||||||
|
): URLSearchParams {
|
||||||
|
const params = new URLSearchParams(base);
|
||||||
|
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("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). */
|
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
|
||||||
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
||||||
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
||||||
@ -55,7 +87,16 @@ export function ObjectsTable({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQDraft(filters.q);
|
setQDraft(filters.q);
|
||||||
}, [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 pager = useCursorPager(filterKey);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@ -67,6 +108,7 @@ export function ObjectsTable({
|
|||||||
type: filters.type || undefined,
|
type: filters.type || undefined,
|
||||||
result: filters.result || undefined,
|
result: filters.result || undefined,
|
||||||
rejected: filters.rejectedOnly || undefined,
|
rejected: filters.rejectedOnly || undefined,
|
||||||
|
reason: filters.reason || undefined,
|
||||||
q: filters.q || undefined,
|
q: filters.q || undefined,
|
||||||
}),
|
}),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
@ -101,7 +143,9 @@ export function ObjectsTable({
|
|||||||
{
|
{
|
||||||
key: "result",
|
key: "result",
|
||||||
header: "Result",
|
header: "Result",
|
||||||
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
// Raw `result` value — the Result filter options must match what the
|
||||||
|
// column displays (rejection is visible via the reason column/filter).
|
||||||
|
render: (obj) => <StatusPill status={obj.result} />,
|
||||||
},
|
},
|
||||||
...(showReason
|
...(showReason
|
||||||
? [
|
? [
|
||||||
@ -162,6 +206,17 @@ export function ObjectsTable({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor={`${queryKeyScope}-reason`}>Reason contains</label>
|
||||||
|
<input
|
||||||
|
id={`${queryKeyScope}-reason`}
|
||||||
|
type="search"
|
||||||
|
className="mono"
|
||||||
|
value={filters.reason}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, reason: e.target.value })}
|
||||||
|
placeholder="substring…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="filter-field">
|
<div className="filter-field">
|
||||||
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
||||||
<input
|
<input
|
||||||
@ -174,7 +229,7 @@ export function ObjectsTable({
|
|||||||
if (e.key === "Enter") submitQ();
|
if (e.key === "Enter") submitQ();
|
||||||
}}
|
}}
|
||||||
onBlur={submitQ}
|
onBlur={submitQ}
|
||||||
placeholder="substring…"
|
placeholder="substring… (applies as you type)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="filter-check">
|
<label className="filter-check">
|
||||||
@ -186,7 +241,7 @@ export function ObjectsTable({
|
|||||||
Rejected only
|
Rejected only
|
||||||
</label>
|
</label>
|
||||||
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
||||||
server-side filters
|
server-side filters · URI search applies as you type
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -182,12 +182,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan
|
|||||||
placeholderData: (prev) => prev,
|
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",
|
key: "serial",
|
||||||
header: "Serial number",
|
header: "Serial number",
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
|
<CopyableValue
|
||||||
|
value={row.serialNumberHex ?? (row.serialNumber != null ? String(row.serialNumber) : null)}
|
||||||
|
max={44}
|
||||||
|
label="serial number"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
||||||
|
|||||||
@ -129,6 +129,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
||||||
|
<a href="#main-content" className="skip-link">
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
<div className="shell-brand">
|
<div className="shell-brand">
|
||||||
<span className="brand-badge" aria-hidden="true">
|
<span className="brand-badge" aria-hidden="true">
|
||||||
<ShieldCheck size={15} />
|
<ShieldCheck size={15} />
|
||||||
@ -170,7 +173,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
|||||||
<div className="nav-footer">ours RP query layer</div>
|
<div className="nav-footer">ours RP query layer</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="shell-main">{children}</main>
|
<main className="shell-main" id="main-content" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,9 @@ export function Tabs({
|
|||||||
role="tab"
|
role="tab"
|
||||||
id={`tab-${tab.id}`}
|
id={`tab-${tab.id}`}
|
||||||
aria-selected={selected}
|
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}
|
tabIndex={selected ? 0 : -1}
|
||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
/** 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 {
|
export interface CursorPager {
|
||||||
/** Cursor to pass to the backend for the current page (null = first page). */
|
/** 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…).
|
* 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 {
|
export function useCursorPager(resetKey: string): CursorPager {
|
||||||
const [state, setState] = useState<PagerState>(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(() => {
|
useEffect(() => {
|
||||||
setState(INITIAL_PAGER_STATE);
|
if (lastResetKey.current === resetKey) return;
|
||||||
}, [resetKey]);
|
lastResetKey.current = resetKey;
|
||||||
|
setStack([]);
|
||||||
|
writeCursor(null);
|
||||||
|
}, [resetKey, writeCursor]);
|
||||||
|
|
||||||
const goNext = useCallback((nextCursor: string | null) => {
|
const goNext = useCallback(
|
||||||
setState((s) => advancePager(s, nextCursor));
|
(nextCursor: string | null) => {
|
||||||
}, []);
|
if (!nextCursor) return;
|
||||||
|
setStack((s) => [...s, cursor]);
|
||||||
|
writeCursor(nextCursor);
|
||||||
|
},
|
||||||
|
[cursor, writeCursor],
|
||||||
|
);
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cursor: state.cursor,
|
cursor,
|
||||||
page: pageNumber(state),
|
page: stack.length + 1,
|
||||||
canPrev: state.stack.length > 0,
|
canPrev: stack.length > 0,
|
||||||
goNext,
|
goNext,
|
||||||
goPrev,
|
goPrev,
|
||||||
reset,
|
reset,
|
||||||
}),
|
}),
|
||||||
[state, goNext, goPrev, reset],
|
[cursor, stack, goNext, goPrev, reset],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,14 @@ describe("formatPercent", () => {
|
|||||||
it("guards zero total", () => {
|
it("guards zero total", () => {
|
||||||
expect(formatPercent(1, 0)).toBe("—");
|
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", () => {
|
describe("truncateMiddle", () => {
|
||||||
|
|||||||
@ -70,7 +70,12 @@ export function formatRelative(value: string | null | undefined): string {
|
|||||||
|
|
||||||
export function formatPercent(part: number, total: number): string {
|
export function formatPercent(part: number, total: number): string {
|
||||||
if (!total) return "—";
|
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. */
|
/** Truncate a long identifier for display, keeping head and tail. */
|
||||||
|
|||||||
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
@ -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<string | null>(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 };
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
/** Resolve the active run record for the current `?run=` context. */
|
/** Resolve the active run record for the current `?run=` context. */
|
||||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
import { getRun } from "../api/service";
|
import { getRun } from "../api/service";
|
||||||
import type { RunRecord } from "../api/schemas";
|
import type { RunRecord } from "../api/schemas";
|
||||||
import { useRunId } from "./run";
|
import { LATEST_RUN, useRunId } from "./run";
|
||||||
|
|
||||||
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
||||||
const runId = useRunId();
|
const runId = useRunId();
|
||||||
@ -13,3 +14,16 @@ export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> }
|
|||||||
});
|
});
|
||||||
return { runId, runQuery };
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -94,4 +94,34 @@ describe("classifyNetworkQuery", () => {
|
|||||||
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
||||||
expect(classifyNetworkQuery("")).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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type NetworkQuery =
|
export type NetworkQuery =
|
||||||
| { kind: "ip"; ip: string }
|
| { kind: "ip"; ip: string; asn?: number }
|
||||||
| { kind: "prefix"; prefix: string }
|
| { kind: "prefix"; prefix: string; asn?: number }
|
||||||
| { kind: "asn"; asn: number }
|
| { kind: "asn"; asn: number }
|
||||||
| { kind: "none" };
|
| { kind: "none" };
|
||||||
|
|
||||||
@ -90,10 +90,30 @@ export function parsePrefix(input: string): string | null {
|
|||||||
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
* 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
|
* ("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.)
|
* 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 {
|
export function classifyNetworkQuery(input: string): NetworkQuery {
|
||||||
const s = input.trim();
|
const s = input.trim();
|
||||||
if (!s) return { kind: "none" };
|
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);
|
const prefix = parsePrefix(s);
|
||||||
if (prefix) return { kind: "prefix", prefix };
|
if (prefix) return { kind: "prefix", prefix };
|
||||||
const ip = parseIpAddress(s);
|
const ip = parseIpAddress(s);
|
||||||
|
|||||||
@ -80,7 +80,6 @@ export default function ApiStatusPage() {
|
|||||||
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
||||||
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
||||||
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
||||||
<li>VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@ -5,16 +5,16 @@ import { DataTable, type Column } from "../components/DataTable";
|
|||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
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 { 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
|
* Export job history for the active run. Jobs are held in the query service
|
||||||
* process memory, so the list resets when the service restarts.
|
* process memory, so the list resets when the service restarts.
|
||||||
*/
|
*/
|
||||||
export default function ExportsPage() {
|
export default function ExportsPage() {
|
||||||
const { runId } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
|
|
||||||
const exportsQuery = useQuery({
|
const exportsQuery = useQuery({
|
||||||
queryKey: ["exports", runId],
|
queryKey: ["exports", runId],
|
||||||
@ -25,6 +25,19 @@ export default function ExportsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (runQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Exports" />
|
||||||
|
<ErrorBlock
|
||||||
|
error={runQuery.error}
|
||||||
|
onRetry={() => runQuery.refetch()}
|
||||||
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const columns: Column<ExportJobRecord>[] = [
|
const columns: Column<ExportJobRecord>[] = [
|
||||||
{
|
{
|
||||||
key: "job",
|
key: "job",
|
||||||
|
|||||||
@ -4,9 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
||||||
import { apiFetchBlob, saveBlob } from "../api/client";
|
import { apiFetchBlob, saveBlob } from "../api/client";
|
||||||
import {
|
import {
|
||||||
createExport,
|
|
||||||
explainObjectValidation,
|
explainObjectValidation,
|
||||||
getExportJob,
|
|
||||||
getObject,
|
getObject,
|
||||||
getObjectChain,
|
getObjectChain,
|
||||||
getObjectProjection,
|
getObjectProjection,
|
||||||
@ -23,8 +21,9 @@ import { StatusPill } from "../components/StatusPill";
|
|||||||
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||||
import { TabPanel, Tabs } from "../components/Tabs";
|
import { TabPanel, Tabs } from "../components/Tabs";
|
||||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||||
import { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
|
import { formatInt, objectTypeLabel } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useExportJob } from "../lib/useExportJob";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
||||||
@ -207,7 +206,6 @@ export default function ObjectDetailPage() {
|
|||||||
const { runId } = useRun();
|
const { runId } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get("tab") ?? "parsed";
|
const tab = searchParams.get("tab") ?? "parsed";
|
||||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const objectQuery = useQuery({
|
const objectQuery = useQuery({
|
||||||
queryKey: ["object", runId, objectInstanceId],
|
queryKey: ["object", runId, objectInstanceId],
|
||||||
@ -229,17 +227,11 @@ export default function ObjectDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const {
|
||||||
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
|
startMutation: exportMutation,
|
||||||
onSuccess: (job) => setExportJobId(job.jobId),
|
jobQuery: exportJobQuery,
|
||||||
});
|
running: exportRunning,
|
||||||
|
} = useExportJob(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] });
|
||||||
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) {
|
if (objectQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@ -298,7 +290,7 @@ export default function ObjectDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => exportMutation.mutate()}
|
onClick={() => exportMutation.mutate()}
|
||||||
disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"}
|
disabled={exportRunning}
|
||||||
>
|
>
|
||||||
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
||||||
Export object set
|
Export object set
|
||||||
@ -317,6 +309,12 @@ export default function ObjectDetailPage() {
|
|||||||
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
||||||
|
{exportJobQuery.data?.status === "complete" ? (
|
||||||
|
<Notice kind="info">
|
||||||
|
Export complete — {formatInt(exportJobQuery.data.objectCount)} objects. Find it any
|
||||||
|
time on the <Link to={withRunParam("/exports", runId)}>Exports page</Link>.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Panel className="object-header-card">
|
<Panel className="object-header-card">
|
||||||
<dl className="meta-grid">
|
<dl className="meta-grid">
|
||||||
@ -334,8 +332,6 @@ export default function ObjectDetailPage() {
|
|||||||
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
||||||
<dt>Detail</dt>
|
<dt>Detail</dt>
|
||||||
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
||||||
<dt>Size</dt>
|
|
||||||
<dd>{formatBytes(object.sizeBytes)}</dd>
|
|
||||||
<dt>Repository</dt>
|
<dt>Repository</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { listObjects } from "../api/service";
|
|||||||
import { Notice } from "../components/StateBlock";
|
import { Notice } from "../components/StateBlock";
|
||||||
import {
|
import {
|
||||||
EMPTY_OBJECT_FILTERS,
|
EMPTY_OBJECT_FILTERS,
|
||||||
|
objectFiltersFromParams,
|
||||||
|
objectFiltersToParams,
|
||||||
ObjectsTable,
|
ObjectsTable,
|
||||||
type ObjectFilterValues,
|
type ObjectFilterValues,
|
||||||
} from "../components/ObjectsTable";
|
} from "../components/ObjectsTable";
|
||||||
@ -11,37 +13,19 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { useRun } from "../lib/useRun";
|
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() {
|
export default function ObjectsPage() {
|
||||||
const { runId } = useRun();
|
const { runId } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
|
const filters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
|
||||||
|
|
||||||
const setFilters = (next: ObjectFilterValues) => {
|
const setFilters = (next: ObjectFilterValues) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
|
||||||
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 =
|
const hasFilters =
|
||||||
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
||||||
filters.result !== "" ||
|
filters.result !== "" ||
|
||||||
|
filters.reason !== "" ||
|
||||||
filters.rejectedOnly ||
|
filters.rejectedOnly ||
|
||||||
filters.q !== "";
|
filters.q !== "";
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ import {
|
|||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
|
|
||||||
/** Semantic colors keyed by validation result — never assigned by index. */
|
/** Semantic colors keyed by validation result — never assigned by index. */
|
||||||
const RESULT_COLORS: Record<string, string> = {
|
const RESULT_COLORS: Record<string, string> = {
|
||||||
@ -126,7 +126,7 @@ export default function OverviewPage() {
|
|||||||
<ErrorBlock
|
<ErrorBlock
|
||||||
error={runQuery.error}
|
error={runQuery.error}
|
||||||
onRetry={() => runQuery.refetch()}
|
onRetry={() => runQuery.refetch()}
|
||||||
title="Failed to load the latest run"
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -170,6 +170,17 @@ export default function OverviewPage() {
|
|||||||
numeric: true,
|
numeric: true,
|
||||||
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "terminal",
|
||||||
|
header: "Terminal states",
|
||||||
|
render: (repo) => (
|
||||||
|
<span className="chip-list">
|
||||||
|
{Object.entries(repo.terminalStates ?? {}).map(([state, count]) => (
|
||||||
|
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -224,6 +235,7 @@ export default function OverviewPage() {
|
|||||||
value={formatInt(counts?.warnings)}
|
value={formatInt(counts?.warnings)}
|
||||||
tone="amber"
|
tone="amber"
|
||||||
sub="objects with warnings"
|
sub="objects with warnings"
|
||||||
|
to={withRunParam("/validation", runId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -238,7 +250,7 @@ export default function OverviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="chart-box">
|
<div className="chart-box">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={validationData}
|
data={validationData}
|
||||||
@ -286,7 +298,7 @@ export default function OverviewPage() {
|
|||||||
<p className="text-muted">No object type stats recorded.</p>
|
<p className="text-muted">No object type stats recorded.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="chart-box">
|
<div className="chart-box">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||||
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<YAxis
|
<YAxis
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { PackageOpen, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getPublicationPoint,
|
getPublicationPoint,
|
||||||
listPublicationPointObjects,
|
listPublicationPointObjects,
|
||||||
@ -8,27 +9,37 @@ import {
|
|||||||
import { CopyableValue } from "../components/CopyableValue";
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
import { KpiCard } from "../components/KpiCard";
|
import { KpiCard } from "../components/KpiCard";
|
||||||
import {
|
import {
|
||||||
EMPTY_OBJECT_FILTERS,
|
objectFiltersFromParams,
|
||||||
|
objectFiltersToParams,
|
||||||
ObjectsTable,
|
ObjectsTable,
|
||||||
type ObjectFilterValues,
|
type ObjectFilterValues,
|
||||||
} from "../components/ObjectsTable";
|
} from "../components/ObjectsTable";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||||
|
import { ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||||
import { formatDurationMs, formatInt, formatUtc } from "../lib/format";
|
import { formatDurationMs, formatInt, formatUtc } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useExportJob } from "../lib/useExportJob";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
export default function PublicationPointDetailPage() {
|
export default function PublicationPointDetailPage() {
|
||||||
const { ppId = "" } = useParams();
|
const { ppId = "" } = useParams();
|
||||||
const { runId } = useRun();
|
const { runId } = useRun();
|
||||||
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(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({
|
const ppQuery = useQuery({
|
||||||
queryKey: ["pp", runId, ppId],
|
queryKey: ["pp", runId, ppId],
|
||||||
queryFn: () => getPublicationPoint(runId, ppId),
|
queryFn: () => getPublicationPoint(runId, ppId),
|
||||||
});
|
});
|
||||||
|
const exportJob = useExportJob(runId, { scope: "publication_point", ppId });
|
||||||
|
|
||||||
if (ppQuery.isError) {
|
if (ppQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@ -60,9 +71,31 @@ export default function PublicationPointDetailPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={<span className="mono" style={{ fontSize: 16 }}>{pp.manifestRsyncUri ?? pp.ppId}</span>}
|
title={<span className="mono" style={{ fontSize: 16 }}>{pp.manifestRsyncUri ?? pp.ppId}</span>}
|
||||||
subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined}
|
subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined}
|
||||||
actions={<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />}
|
actions={
|
||||||
|
<>
|
||||||
|
<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => exportJob.startMutation.mutate()}
|
||||||
|
disabled={exportJob.running}
|
||||||
|
>
|
||||||
|
{exportJob.startMutation.isPending ? (
|
||||||
|
<RefreshCw size={13} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<PackageOpen size={13} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{exportJob.startMutation.isError ? (
|
||||||
|
<Notice kind="error">Export failed to start: {exportJob.startMutation.error.message}</Notice>
|
||||||
|
) : null}
|
||||||
|
{exportJob.jobQuery.data ? <WorkflowStatus job={exportJob.jobQuery.data} runId={runId} /> : null}
|
||||||
|
|
||||||
<div className="kpi-grid">
|
<div className="kpi-grid">
|
||||||
<KpiCard label="Objects" value={formatInt(pp.objects)} />
|
<KpiCard label="Objects" value={formatInt(pp.objects)} />
|
||||||
<KpiCard label="Rejected" value={formatInt(pp.rejectedObjects)} tone="red" />
|
<KpiCard label="Rejected" value={formatInt(pp.rejectedObjects)} tone="red" />
|
||||||
|
|||||||
@ -104,7 +104,8 @@ export default function PublicationPointsPage() {
|
|||||||
loading={ppsQuery.isPending}
|
loading={ppsQuery.isPending}
|
||||||
error={ppsQuery.isError ? ppsQuery.error : undefined}
|
error={ppsQuery.isError ? ppsQuery.error : undefined}
|
||||||
onRetry={() => ppsQuery.refetch()}
|
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"
|
caption="Publication points"
|
||||||
onRowClick={(pp) =>
|
onRowClick={(pp) =>
|
||||||
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
|
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
|
||||||
|
|||||||
@ -122,8 +122,12 @@ export default function RepositoriesPage() {
|
|||||||
loading={reposQuery.isPending}
|
loading={reposQuery.isPending}
|
||||||
error={reposQuery.isError ? reposQuery.error : undefined}
|
error={reposQuery.isError ? reposQuery.error : undefined}
|
||||||
onRetry={() => reposQuery.refetch()}
|
onRetry={() => reposQuery.refetch()}
|
||||||
emptyTitle="No repositories indexed"
|
emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No repositories indexed"}
|
||||||
emptyHint="The query service has not indexed any run yet."
|
emptyHint={
|
||||||
|
needle.trim()
|
||||||
|
? "The filter only narrows the rows of the current page."
|
||||||
|
: "The query service has not indexed any run yet."
|
||||||
|
}
|
||||||
caption="Repositories"
|
caption="Repositories"
|
||||||
onRowClick={(repo) =>
|
onRowClick={(repo) =>
|
||||||
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { PackageOpen, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getRepo,
|
getRepo,
|
||||||
getRepoStats,
|
getRepoStats,
|
||||||
@ -13,7 +14,8 @@ import { CursorPagerControls } from "../components/CursorPagerControls";
|
|||||||
import { DataTable, type Column } from "../components/DataTable";
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
import { KpiCard } from "../components/KpiCard";
|
import { KpiCard } from "../components/KpiCard";
|
||||||
import {
|
import {
|
||||||
EMPTY_OBJECT_FILTERS,
|
objectFiltersFromParams,
|
||||||
|
objectFiltersToParams,
|
||||||
ObjectsTable,
|
ObjectsTable,
|
||||||
type ObjectFilterValues,
|
type ObjectFilterValues,
|
||||||
} from "../components/ObjectsTable";
|
} from "../components/ObjectsTable";
|
||||||
@ -21,10 +23,12 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { TabPanel, Tabs } from "../components/Tabs";
|
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 { useCursorPager } from "../lib/cursor";
|
||||||
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
|
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useExportJob } from "../lib/useExportJob";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
|
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
|
||||||
@ -109,7 +113,12 @@ export default function RepositoryDetailPage() {
|
|||||||
const { runId, runQuery } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get("tab") ?? "pps";
|
const tab = searchParams.get("tab") ?? "pps";
|
||||||
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(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({
|
const repoQuery = useQuery({
|
||||||
queryKey: ["repo", runId, repoId],
|
queryKey: ["repo", runId, repoId],
|
||||||
@ -119,6 +128,7 @@ export default function RepositoryDetailPage() {
|
|||||||
queryKey: ["repo-stats", runId, repoId],
|
queryKey: ["repo-stats", runId, repoId],
|
||||||
queryFn: () => getRepoStats(runId, repoId),
|
queryFn: () => getRepoStats(runId, repoId),
|
||||||
});
|
});
|
||||||
|
const exportJob = useExportJob(runId, { scope: "repo", repoId });
|
||||||
|
|
||||||
if (repoQuery.isError) {
|
if (repoQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@ -157,9 +167,31 @@ export default function RepositoryDetailPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={repo.host}
|
title={repo.host}
|
||||||
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
|
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
|
||||||
actions={<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />}
|
actions={
|
||||||
|
<>
|
||||||
|
<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => exportJob.startMutation.mutate()}
|
||||||
|
disabled={exportJob.running}
|
||||||
|
>
|
||||||
|
{exportJob.startMutation.isPending ? (
|
||||||
|
<RefreshCw size={13} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<PackageOpen size={13} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{exportJob.startMutation.isError ? (
|
||||||
|
<Notice kind="error">Export failed to start: {exportJob.startMutation.error.message}</Notice>
|
||||||
|
) : null}
|
||||||
|
{exportJob.jobQuery.data ? <WorkflowStatus job={exportJob.jobQuery.data} runId={runId} /> : null}
|
||||||
|
|
||||||
<div className="kpi-grid">
|
<div className="kpi-grid">
|
||||||
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
|
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
|
||||||
<KpiCard label="Objects" value={formatInt(repo.objects)} />
|
<KpiCard label="Objects" value={formatInt(repo.objects)} />
|
||||||
|
|||||||
@ -95,6 +95,7 @@ export default function RunsPage() {
|
|||||||
className="btn small"
|
className="btn small"
|
||||||
disabled={active}
|
disabled={active}
|
||||||
onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)}
|
onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)}
|
||||||
|
aria-label={active ? `Run ${run.runId} is active` : `Use run ${run.runId}`}
|
||||||
>
|
>
|
||||||
{active ? "Active" : "Use run"}
|
{active ? "Active" : "Use run"}
|
||||||
</button>
|
</button>
|
||||||
@ -103,6 +104,7 @@ export default function RunsPage() {
|
|||||||
className="btn small"
|
className="btn small"
|
||||||
onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))}
|
onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))}
|
||||||
aria-expanded={expandedRun === run.runId}
|
aria-expanded={expandedRun === run.runId}
|
||||||
|
aria-label={`Show artifacts for run ${run.runId}`}
|
||||||
>
|
>
|
||||||
Artifacts
|
Artifacts
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
|
|||||||
import { useCursorPager } from "../lib/cursor";
|
import { useCursorPager } from "../lib/cursor";
|
||||||
import { objectTypeLabel } from "../lib/format";
|
import { objectTypeLabel } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
import { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery";
|
import { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery";
|
||||||
|
|
||||||
const VRP_PAGE_SIZE = 50;
|
const VRP_PAGE_SIZE = 50;
|
||||||
@ -139,7 +139,7 @@ function VrpResults({ network }: { network: NetworkQuery & { kind: "ip" | "prefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const { runId } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
const [draft, setDraft] = useState(q);
|
const [draft, setDraft] = useState(q);
|
||||||
@ -149,6 +149,18 @@ export default function SearchPage() {
|
|||||||
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
|
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
|
||||||
const isVrpLookup = network.kind === "ip" || network.kind === "prefix";
|
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({
|
const searchQuery = useQuery({
|
||||||
queryKey: ["search", runId, q],
|
queryKey: ["search", runId, q],
|
||||||
queryFn: () => searchRun(runId, q, 10),
|
queryFn: () => searchRun(runId, q, 10),
|
||||||
@ -164,6 +176,19 @@ export default function SearchPage() {
|
|||||||
setSearchParams(params, { preventScrollReset: true });
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (runQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Search" />
|
||||||
|
<ErrorBlock
|
||||||
|
error={runQuery.error}
|
||||||
|
onRetry={() => runQuery.refetch()}
|
||||||
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = searchQuery.data;
|
const result = searchQuery.data;
|
||||||
const totalHits =
|
const totalHits =
|
||||||
(result?.objects.length ?? 0) +
|
(result?.objects.length ?? 0) +
|
||||||
@ -287,6 +312,7 @@ export default function SearchPage() {
|
|||||||
<li><code>rsync://</code> / <code>https://</code> URI — exact object match</li>
|
<li><code>rsync://</code> / <code>https://</code> URI — exact object match</li>
|
||||||
<li>64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan</li>
|
<li>64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan</li>
|
||||||
<li>IPv4 / IPv6 address or CIDR prefix — VRP lookup (covering VRPs), with optional ASN filter</li>
|
<li>IPv4 / IPv6 address or CIDR prefix — VRP lookup (covering VRPs), with optional ASN filter</li>
|
||||||
|
<li>IP / prefix + ASN in one query (<code>1.0.0.1 AS13335</code>) — prefills the VRP ASN filter</li>
|
||||||
<li><code>AS</code> number alone — not supported by the VRP API; combine with an IP or prefix</li>
|
<li><code>AS</code> number alone — not supported by the VRP API; combine with an IP or prefix</li>
|
||||||
<li>any other text — substring match on repository host/URI and publication point URIs</li>
|
<li>any other text — substring match on repository host/URI and publication point URIs</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -19,11 +19,11 @@ import type { ObjectInstanceRecord } from "../api/schemas";
|
|||||||
import { useCursorPager } from "../lib/cursor";
|
import { useCursorPager } from "../lib/cursor";
|
||||||
import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format";
|
import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function ValidationPage() {
|
export default function ValidationPage() {
|
||||||
const { runId } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const reason = searchParams.get("reason") ?? "";
|
const reason = searchParams.get("reason") ?? "";
|
||||||
@ -88,6 +88,19 @@ export default function ValidationPage() {
|
|||||||
setSearchParams(params, { preventScrollReset: true });
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (runQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Validation" />
|
||||||
|
<ErrorBlock
|
||||||
|
error={runQuery.error}
|
||||||
|
onRetry={() => runQuery.refetch()}
|
||||||
|
title={runErrorTitle(runId, runQuery.error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const columns: Column<ObjectInstanceRecord>[] = [
|
const columns: Column<ObjectInstanceRecord>[] = [
|
||||||
{
|
{
|
||||||
key: "type",
|
key: "type",
|
||||||
|
|||||||
@ -149,6 +149,19 @@
|
|||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 0 0 var(--radius-l) var(--radius-l);
|
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 {
|
table.data-table {
|
||||||
|
|||||||
@ -13,6 +13,36 @@
|
|||||||
grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr);
|
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 {
|
.shell-brand {
|
||||||
grid-area: brand;
|
grid-area: brand;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -294,6 +324,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: var(--space-2);
|
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,
|
.shell-nav .nav-toggle,
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
--text-1: #0f172a;
|
--text-1: #0f172a;
|
||||||
--text-2: #475569;
|
--text-2: #475569;
|
||||||
--text-3: #8a97a8;
|
--text-3: #64748b;
|
||||||
|
|
||||||
--bg-0: #f4f6fa;
|
--bg-0: #f4f6fa;
|
||||||
--bg-1: #ffffff;
|
--bg-1: #ffffff;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user