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:
@@ -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") {
|
||||
|
||||
227
src/query_db.rs
227
src/query_db.rs
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user