20260728_2 修复rpki-explorer体验问题7项(#134):证书资源可展开、CMS对象新增EE Parsed Tab、Overview增加repo数KPI/修复Object types标签/Top repositories按objects排序(后端sort参数)/reject reasons按前缀聚类可下钻、Run选择器30s轮询

This commit is contained in:
2026-07-28 13:21:58 +08:00
parent 0d6bbec9f3
commit f7f80fe684
20 changed files with 676 additions and 70 deletions

View File

@@ -11,7 +11,7 @@ use rpki::query::report_stream::{ObjectFilter, ObjectScope};
use rpki::query::vrp::VrpLookup;
use rpki::query_db::{
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
QueryDbError, ValidationExplainRecord,
QueryDbError, RepoSortOrder, ValidationExplainRecord,
};
use serde::Serialize;
use serde_json::{Value, json};
@@ -646,10 +646,11 @@ fn route_request(
}
["runs", raw_run_id, "repos"] => {
let run_id = resolve_run(db, raw_run_id)?;
page_response(
db.list_repos(&run_id, limit(&query), cursor(&query))?,
Some(run_id),
)
let page = match repo_sort_from_query(&query)? {
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
};
page_response(page, Some(run_id))
}
["runs", raw_run_id, "repos", repo_id] => {
let run_id = resolve_run(db, raw_run_id)?;
@@ -936,10 +937,11 @@ fn route_request(
}
["runs", raw_run_id, "stats", "repos"] => {
let run_id = resolve_run(db, raw_run_id)?;
page_response(
db.list_repos(&run_id, limit(&query), cursor(&query))?,
Some(run_id),
)
let page = match repo_sort_from_query(&query)? {
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
};
page_response(page, Some(run_id))
}
["runs", raw_run_id, "stats", "publication-points"] => {
let run_id = resolve_run(db, raw_run_id)?;
@@ -2155,6 +2157,18 @@ fn exports_limit(query: &BTreeMap<String, String>) -> usize {
.clamp(1, 200)
}
fn repo_sort_from_query(query: &BTreeMap<String, String>) -> Result<Option<RepoSortOrder>, ApiError> {
match query.get("sort").map(|value| value.as_str()) {
None => Ok(None),
Some("objects") => match query.get("order").map(|value| value.as_str()) {
None | Some("desc") => Ok(Some(RepoSortOrder::ObjectsDesc)),
Some("asc") => Ok(Some(RepoSortOrder::ObjectsAsc)),
Some(other) => Err(ApiError::new(400, format!("invalid repos order: {other}"))),
},
Some(other) => Err(ApiError::new(400, format!("invalid repos sort: {other}"))),
}
}
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
let mut filter = ObjectFilter::default();
if let Some(value) = query.get("type") {

View File

@@ -162,6 +162,24 @@ pub struct RepositoryRecord {
pub terminal_states: BTreeMap<String, u64>,
}
/// Sort order for `QueryDb::list_repos_sorted`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepoSortOrder {
ObjectsDesc,
ObjectsAsc,
}
/// Parse a `repos:<offset>` cursor used by sorted repository listings.
fn parse_repos_offset_cursor(cursor: Option<&str>) -> QueryDbResult<usize> {
match cursor {
None => Ok(0),
Some(raw) => raw
.strip_prefix("repos:")
.and_then(|value| value.parse::<usize>().ok())
.ok_or_else(|| QueryDbError::InvalidArtifact(format!("invalid repos cursor: {raw}"))),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicationPointRecord {
@@ -410,6 +428,58 @@ impl QueryDb {
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
}
/// List repositories sorted by object count. The sort happens at query
/// time over the run's repo set (hundreds of records at most), so no
/// extra index is needed. Pagination uses a `repos:<offset>` cursor,
/// independent from the index-order key cursor used by `list_repos`.
pub fn list_repos_sorted(
&self,
run_id: &str,
limit: usize,
cursor: Option<&str>,
order: RepoSortOrder,
) -> QueryDbResult<QueryPage<RepositoryRecord>> {
let cf = self.cf(CF_REPOS)?;
let prefix = format!("repo/{run_id}/");
let mut records = Vec::new();
for item in self.db.iterator_cf(
cf,
IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward),
) {
let (key, value) = item?;
if !key.starts_with(prefix.as_bytes()) {
break;
}
records.push(serde_json::from_slice::<RepositoryRecord>(&value)?);
}
records.sort_by(|left, right| {
let by_objects = match order {
RepoSortOrder::ObjectsDesc => right.objects.cmp(&left.objects),
RepoSortOrder::ObjectsAsc => left.objects.cmp(&right.objects),
};
// Stable, deterministic order for equal object counts.
by_objects.then_with(|| left.repo_id.cmp(&right.repo_id))
});
let offset = parse_repos_offset_cursor(cursor)?;
let bounded_limit = limit.clamp(1, 1000);
let total = records.len();
let data = records
.into_iter()
.skip(offset)
.take(bounded_limit)
.collect::<Vec<_>>();
let next_cursor = if offset + bounded_limit < total {
Some(format!("repos:{}", offset + bounded_limit))
} else {
None
};
Ok(QueryPage {
data,
next_cursor,
limit: bounded_limit,
})
}
pub fn lookup_vrps(
&self,
run_id: &str,
@@ -2286,6 +2356,163 @@ mod tests {
.expect("report");
}
#[test]
fn query_db_list_repos_sorted_orders_by_object_count() {
let temp = tempfile::tempdir().expect("tempdir");
let run1 = temp.path().join("runs/run_0001");
fs::create_dir_all(&run1).expect("run1");
// Index order (a, b, c) deliberately differs from object-count order.
write_sample_run_with_repos(
&run1,
"run_0001",
1,
&[
("repo-a.example", 2),
("repo-b.example", 5),
("repo-c.example", 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");
let hosts = |page: QueryPage<RepositoryRecord>| {
page.data
.iter()
.map(|repo| (repo.host.clone(), repo.objects))
.collect::<Vec<_>>()
};
let desc = db
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsDesc)
.expect("desc");
assert_eq!(
hosts(desc),
vec![
("repo-b.example".to_string(), 5),
("repo-a.example".to_string(), 2),
("repo-c.example".to_string(), 1),
]
);
let asc = db
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsAsc)
.expect("asc");
assert_eq!(
hosts(asc),
vec![
("repo-c.example".to_string(), 1),
("repo-a.example".to_string(), 2),
("repo-b.example".to_string(), 5),
]
);
// Offset cursor pagination over the sorted listing.
let first = db
.list_repos_sorted("run_0001", 2, None, RepoSortOrder::ObjectsDesc)
.expect("first page");
assert_eq!(first.data.len(), 2);
assert_eq!(first.data[0].host, "repo-b.example");
assert_eq!(first.next_cursor.as_deref(), Some("repos:2"));
let second = db
.list_repos_sorted(
"run_0001",
2,
first.next_cursor.as_deref(),
RepoSortOrder::ObjectsDesc,
)
.expect("second page");
assert_eq!(second.data.len(), 1);
assert_eq!(second.data[0].host, "repo-c.example");
assert!(second.next_cursor.is_none());
// A key-style cursor from the index-order listing is rejected.
assert!(db
.list_repos_sorted("run_0001", 2, Some("repo/run_0001/x"), RepoSortOrder::ObjectsDesc)
.is_err());
}
/// Sample run with one publication point per repo host, each holding
/// `object_count` ok objects, so repo records get distinct object counts.
fn write_sample_run_with_repos(
run_dir: &Path,
run_id: &str,
run_seq: u64,
repo_specs: &[(&str, usize)],
) {
let publication_points = repo_specs
.iter()
.enumerate()
.map(|(index, (host, object_count))| {
let objects = (0..*object_count)
.map(|i| {
json!({
"rsync_uri": format!("rsync://{host}/rpki/obj{i}.roa"),
"sha256_hex": format!("{index:02x}{i:02x}"),
"kind": "roa",
"result": "ok"
})
})
.collect::<Vec<_>>();
json!({
"node_id": 10 + index,
"rsync_base_uri": format!("rsync://{host}/rpki/"),
"manifest_rsync_uri": format!("rsync://{host}/rpki/m.mft"),
"publication_point_rsync_uri": format!("rsync://{host}/rpki/"),
"rrdp_notification_uri": format!("https://{host}/rrdp/notification.xml"),
"source": "rrdp",
"repo_sync_source": "rrdp",
"repo_sync_phase": "rrdp_snapshot",
"repo_sync_duration_ms": 100,
"repo_terminal_state": "fresh",
"warnings": [],
"objects": objects,
})
})
.collect::<Vec<_>>();
let report = json!({
"format_version": 2,
"meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
"tree": {"warnings": []},
"publication_points": publication_points,
"vrps": [],
"aspas": [],
"downloads": [],
"download_stats": {},
"repo_sync_stats": {}
});
fs::write(
run_dir.join("report.json"),
serde_json::to_vec(&report).unwrap(),
)
.expect("report");
let summary = json!({
"status": "success",
"runId": run_id,
"runSeq": run_seq,
"startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
"finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
"wallMs": 60000,
"reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": repo_specs.len(), "warnings": 0}
});
fs::write(
run_dir.join("run-summary.json"),
serde_json::to_vec(&summary).unwrap(),
)
.expect("summary");
fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
}
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
let report = json!({
"format_version": 2,