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:
2026-07-27 13:05:56 +08:00
parent 001f13209f
commit cf68abf642
36 changed files with 1082 additions and 125 deletions

View File

@@ -176,9 +176,14 @@ pub fn list_report_objects_filtered(
start_pp_index,
start_object_index,
);
ObjectScanSeed { state: &mut state }
.deserialize(&mut deserializer)
.map_err(QueryDbError::from)?;
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
match scan {
Ok(()) => {}
// A full page plus its next cursor is all the caller needs; the
// visitor aborts the remaining scan with a sentinel error.
Err(err) if is_object_scan_complete(&err) => {}
Err(err) => return Err(QueryDbError::from(err)),
}
Ok(QueryPage {
data: state.data,
next_cursor: state.next_cursor,
@@ -215,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String {
format!("r1:{pp_index}:{object_index}")
}
/// Sentinel message used to abort the streaming object scan early once a list
/// page (plus its next cursor) has been collected. The deserializer is left
/// mid-document and dropped, which is fine because the caller only needs the
/// state accumulated so far.
const OBJECT_SCAN_COMPLETE_MSG: &str = "rpki report object scan complete: page collected";
fn is_object_scan_complete(err: &serde_json::Error) -> bool {
err.is_data() && err.to_string().starts_with(OBJECT_SCAN_COMPLETE_MSG)
}
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
let Some(cursor) = cursor else {
return Ok((0, 0));
@@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
scanned_objects: 0,
}
}
/// True once a list page is full and its next cursor is fixed; everything
/// after this point cannot alter the page (cursor semantics: the cursor
/// already points at the first item of the next page).
fn list_page_complete(&self) -> bool {
matches!(self.mode, ObjectScanMode::List)
&& self.data.len() >= self.limit
&& self.next_cursor.is_some()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
A: SeqAccess<'de>,
{
let mut pp_index = 0u64;
while let Some(raw_pp) = seq.next_element::<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())
.map_err(de::Error::custom)?;
pp_index += 1;
@@ -822,6 +852,20 @@ fn process_raw_publication_point(
raw_pp: &str,
) -> QueryDbResult<()> {
state.scanned_publication_points += 1;
if matches!(state.mode, ObjectScanMode::List) && pp_index < state.start_pp_index {
// Every object in this PP sits before the cursor, so none of them can
// be part of the page: skip materializing the subtree entirely.
return Ok(());
}
if state.scope != ObjectScope::All {
// Scope is decidable from PP-level fields alone; probe them before
// materializing the (potentially huge) objects subtree.
let probe: PublicationPointScopeProbe = serde_json::from_str(raw_pp)?;
let ctx = PpObjectContext::from_pp(state.run_id, &probe.into_summary());
if !state.scope.matches(&ctx) {
return Ok(());
}
}
let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
if !state.scope.matches(&ctx) {
@@ -909,6 +953,30 @@ impl<'de> Visitor<'de> for PublicationPointForObjectsVisitor {
}
}
/// Lightweight probe of the PP-level fields needed to decide `ObjectScope`
/// before the (potentially huge) objects subtree is materialized.
#[derive(Clone, Debug, Default, Deserialize)]
struct PublicationPointScopeProbe {
node_id: Option<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)]
struct ReportObject {
uri: String,
@@ -1360,4 +1428,195 @@ mod tests {
assert_eq!(found.object.result, "error");
assert_eq!(found.resolution.mode, "report_scan");
}
/// Three PPs with two objects each; used to anchor paging and scope
/// behavior of the early-stop object scan.
fn write_multi_pp_report(report_path: &std::path::Path) {
fs::write(
report_path,
r#"{
"meta":{"validation_time_rfc3339_utc":"2026-06-16T00:00:00Z"},
"tree":{"warnings":[]},
"publication_points":[
{
"node_id":1,
"rsync_base_uri":"rsync://repo-a.example/rpki/",
"manifest_rsync_uri":"rsync://repo-a.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-a.example/rpki/",
"rrdp_notification_uri":"https://repo-a.example/rrdp/notification.xml",
"objects":[
{"rsync_uri":"rsync://repo-a.example/rpki/m.mft","sha256_hex":"a1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-a.example/rpki/a.roa","sha256_hex":"a2","kind":"roa","result":"ok"}
]
},
{
"node_id":2,
"rsync_base_uri":"rsync://repo-b.example/rpki/",
"manifest_rsync_uri":"rsync://repo-b.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-b.example/rpki/",
"objects":[
{"rsync_uri":"rsync://repo-b.example/rpki/m.mft","sha256_hex":"b1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-b.example/rpki/b.roa","sha256_hex":"b2","kind":"roa","result":"error","detail":"bad roa"}
]
},
{
"node_id":3,
"rsync_base_uri":"rsync://repo-c.example/rpki/",
"manifest_rsync_uri":"rsync://repo-c.example/rpki/m.mft",
"publication_point_rsync_uri":"rsync://repo-c.example/rpki/",
"rrdp_notification_uri":"https://repo-c.example/rrdp/notification.xml",
"objects":[
{"rsync_uri":"rsync://repo-c.example/rpki/m.mft","sha256_hex":"c1","kind":"manifest","result":"ok"},
{"rsync_uri":"rsync://repo-c.example/rpki/c.roa","sha256_hex":"c2","kind":"roa","result":"ok"}
]
}
]
}"#,
)
.expect("report");
}
#[test]
fn object_listing_pages_across_publication_points_with_stable_cursors() {
let temp = tempfile::tempdir().expect("tempdir");
let report_path = temp.path().join("report.json");
write_multi_pp_report(&report_path);
// Page 1: fills inside PP 0; the cursor must resume at PP 0 object 2.
let page1 = list_report_objects(&report_path, "run_0001", ObjectScope::All, 2, None)
.expect("page1");
assert_eq!(page1.data.len(), 2);
assert_eq!(page1.data[0].uri, "rsync://repo-a.example/rpki/m.mft");
assert_eq!(page1.data[1].uri, "rsync://repo-a.example/rpki/a.roa");
assert_eq!(page1.next_cursor.as_deref(), Some("r1:0:2"));
// Page 2: PP 0 is exhausted; the two PP 1 objects fill the page.
let page2 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page1.next_cursor.as_deref(),
)
.expect("page2");
assert_eq!(page2.data.len(), 2);
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
assert_eq!(page2.data[1].uri, "rsync://repo-b.example/rpki/b.roa");
assert_eq!(page2.next_cursor.as_deref(), Some("r1:1:2"));
// Page 3: last PP, page fills at the final object.
let page3 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page2.next_cursor.as_deref(),
)
.expect("page3");
assert_eq!(page3.data.len(), 2);
assert_eq!(page3.next_cursor.as_deref(), Some("r1:2:2"));
// Page 4: nothing left.
let page4 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::All,
2,
page3.next_cursor.as_deref(),
)
.expect("page4");
assert!(page4.data.is_empty());
assert_eq!(page4.next_cursor, None);
// A page larger than the remaining tail must not produce a cursor.
let tail = list_report_objects(&report_path, "run_0001", ObjectScope::All, 10, None)
.expect("tail");
assert_eq!(tail.data.len(), 6);
assert_eq!(tail.next_cursor, None);
}
#[test]
fn object_listing_scope_skips_non_matching_publication_points() {
let temp = tempfile::tempdir().expect("tempdir");
let report_path = temp.path().join("report.json");
write_multi_pp_report(&report_path);
// PP scope: only PP 1 (node_id 2) objects, cursor resumes within PP 1.
let page1 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
1,
None,
)
.expect("page1");
assert_eq!(page1.data.len(), 1);
assert_eq!(page1.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
assert_eq!(page1.next_cursor.as_deref(), Some("r1:1:1"));
let page2 = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
10,
page1.next_cursor.as_deref(),
)
.expect("page2");
assert_eq!(page2.data.len(), 1);
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/b.roa");
assert_eq!(page2.next_cursor, None);
// Repo scope derived from the PP without rrdp uri (repo-b).
let probe_pp = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_2".to_string()),
10,
None,
)
.expect("probe");
let repo_id = probe_pp.data[0].repo_id.clone();
let repo_page = list_report_objects(
&report_path,
"run_0001",
ObjectScope::Repo(repo_id),
10,
None,
)
.expect("repo page");
assert_eq!(repo_page.data.len(), 2);
assert!(
repo_page
.data
.iter()
.all(|object| object.uri.contains("repo-b.example"))
);
// Unknown scope id: empty page, no cursor, no error.
let empty = list_report_objects(
&report_path,
"run_0001",
ObjectScope::PublicationPoint("node_999".to_string()),
10,
None,
)
.expect("empty");
assert!(empty.data.is_empty());
assert_eq!(empty.next_cursor, None);
// Filtered listing keeps working across skipped PPs.
let mut filter = ObjectFilter::default();
filter.query = Some("c.roa".to_string());
let filtered = list_report_objects_filtered(
&report_path,
"run_0001",
ObjectScope::All,
&filter,
10,
None,
)
.expect("filtered");
assert_eq!(filtered.data.len(), 1);
assert_eq!(filtered.data[0].uri, "rsync://repo-c.example/rpki/c.roa");
assert_eq!(filtered.next_cursor, None);
}
}