20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
@@ -7,9 +7,10 @@ use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rpki::blob_store::ExternalRepoBytesDb;
|
||||
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
|
||||
use rpki::query_db::{
|
||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError,
|
||||
ValidationExplainRecord,
|
||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
||||
QueryDbError, ValidationExplainRecord,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -544,6 +545,18 @@ fn route_request(
|
||||
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
||||
}
|
||||
match segments.as_slice() {
|
||||
["healthz"] => {
|
||||
let latest_ready_run = db.latest_ready_run()?;
|
||||
data_response(
|
||||
&json!({
|
||||
"status": "ok",
|
||||
"service": "rpki_query_service",
|
||||
"version": 1,
|
||||
"latestReadyRun": latest_ready_run,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
|
||||
["latest_run"] => {
|
||||
let run_id = db
|
||||
@@ -630,8 +643,15 @@ fn route_request(
|
||||
}
|
||||
["runs", raw_run_id, "repos", repo_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects_for_repo(&run_id, repo_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::Repo(repo_id.to_string()),
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
@@ -669,18 +689,63 @@ fn route_request(
|
||||
}
|
||||
["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects_for_pp(&run_id, pp_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::PublicationPoint(pp_id.to_string()),
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "objects"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let filter = object_filter_from_query(&query)?;
|
||||
page_response(
|
||||
db.list_objects(&run_id, limit(&query), cursor(&query))?,
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::All,
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "issues"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let mut filter = object_filter_from_query(&query)?;
|
||||
filter.issues_only = true;
|
||||
filter.repo_id = query.get("repoId").cloned();
|
||||
filter.pp_id = query.get("ppId").cloned();
|
||||
page_response(
|
||||
db.list_objects_filtered(
|
||||
&run_id,
|
||||
ObjectScope::All,
|
||||
&filter,
|
||||
limit(&query),
|
||||
cursor(&query),
|
||||
)?,
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "search"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let q = query
|
||||
.get("q")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if q.is_empty() {
|
||||
return Err(ApiError::new(400, "q query parameter is required"));
|
||||
}
|
||||
let limit = search_limit(&query);
|
||||
data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id))
|
||||
}
|
||||
["runs", raw_run_id, "objects", "by-uri"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let uri = query
|
||||
@@ -792,6 +857,18 @@ fn route_request(
|
||||
offset_cursor(&query),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "exports"] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let limit = exports_limit(&query);
|
||||
page_response(
|
||||
rpki::query_db::QueryPage {
|
||||
data: list_export_jobs(export_jobs, &run_id, limit)?,
|
||||
next_cursor: None,
|
||||
limit,
|
||||
},
|
||||
Some(run_id),
|
||||
)
|
||||
}
|
||||
["runs", raw_run_id, "exports", job_id] => {
|
||||
let run_id = resolve_run(db, raw_run_id)?;
|
||||
let job = get_export_job(db, export_jobs, &run_id, job_id)?
|
||||
@@ -1150,6 +1227,24 @@ fn get_export_job(
|
||||
Ok(db.get_export_job(run_id, job_id)?)
|
||||
}
|
||||
|
||||
fn list_export_jobs(
|
||||
export_jobs: &ExportJobStore,
|
||||
run_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExportJobRecord>, ApiError> {
|
||||
let jobs = export_jobs
|
||||
.lock()
|
||||
.map_err(|_| ApiError::new(500, "export job store lock poisoned"))?;
|
||||
let mut out = jobs
|
||||
.values()
|
||||
.filter(|job| job.run_id == run_id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
out.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
out.truncate(limit);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_export_job(
|
||||
db: &QueryDb,
|
||||
repo_bytes: &ExternalRepoBytesDb,
|
||||
@@ -1575,6 +1670,103 @@ fn projection_for_explain(
|
||||
Ok(Some(projection))
|
||||
}
|
||||
|
||||
fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result<Value, ApiError> {
|
||||
let objects = search_run_objects(db, run_id, q, limit)?;
|
||||
let lower = q.to_lowercase();
|
||||
let repos = db.search_repos(run_id, &lower, limit)?;
|
||||
let publication_points = db.search_publication_points(run_id, &lower, limit)?;
|
||||
Ok(json!({
|
||||
"objects": objects,
|
||||
"repos": repos,
|
||||
"publicationPoints": publication_points,
|
||||
}))
|
||||
}
|
||||
|
||||
fn search_run_objects(
|
||||
db: &QueryDb,
|
||||
run_id: &str,
|
||||
q: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ObjectInstanceRecord>, ApiError> {
|
||||
if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") {
|
||||
let Some(index) = db.get_object_by_uri(run_id, q)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? {
|
||||
Some(full) => full,
|
||||
None => object_instance_from_uri_index(&index),
|
||||
};
|
||||
return Ok(vec![object]);
|
||||
}
|
||||
let lower = q.to_lowercase();
|
||||
if is_sha256_hex(q) {
|
||||
if let Some(projection) = db.get_object_projection(&lower)? {
|
||||
if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
|
||||
return Ok(vec![full]);
|
||||
}
|
||||
return Ok(vec![object_instance_from_projection(run_id, &projection)]);
|
||||
}
|
||||
if let Some(object) = db.get_object_by_sha256(run_id, &lower)? {
|
||||
return Ok(vec![object]);
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit());
|
||||
if is_hex {
|
||||
let mut objects = Vec::new();
|
||||
for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? {
|
||||
let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? {
|
||||
Some(full) => full,
|
||||
None => object_instance_from_projection(run_id, &projection),
|
||||
};
|
||||
objects.push(object);
|
||||
}
|
||||
return Ok(objects);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord {
|
||||
ObjectInstanceRecord {
|
||||
schema_version: 1,
|
||||
run_id: index.run_id.clone(),
|
||||
object_instance_id: index.object_instance_id.clone(),
|
||||
uri: index.uri.clone(),
|
||||
uri_hash: String::new(),
|
||||
sha256: index.sha256.clone(),
|
||||
object_type: String::new(),
|
||||
result: String::new(),
|
||||
detail_summary: None,
|
||||
repo_id: index.repo_id.clone(),
|
||||
pp_id: index.pp_id.clone(),
|
||||
source_section: "uri_index".to_string(),
|
||||
rejected: false,
|
||||
reject_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_instance_from_projection(
|
||||
run_id: &str,
|
||||
projection: &rpki::object_projection::ObjectProjectionRecord,
|
||||
) -> ObjectInstanceRecord {
|
||||
ObjectInstanceRecord {
|
||||
schema_version: 1,
|
||||
run_id: run_id.to_string(),
|
||||
object_instance_id: String::new(),
|
||||
uri: String::new(),
|
||||
uri_hash: String::new(),
|
||||
sha256: projection.sha256.clone(),
|
||||
object_type: projection.object_type.clone(),
|
||||
result: projection.parse_status.clone(),
|
||||
detail_summary: projection.error_summary.clone(),
|
||||
repo_id: String::new(),
|
||||
pp_id: String::new(),
|
||||
source_section: "hash_index".to_string(),
|
||||
rejected: false,
|
||||
reject_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn global_projection_for_hash(
|
||||
db: &QueryDb,
|
||||
repo_bytes: Option<&ExternalRepoBytesDb>,
|
||||
@@ -1878,6 +2070,66 @@ fn limit(query: &BTreeMap<String, String>) -> usize {
|
||||
.clamp(1, 1000)
|
||||
}
|
||||
|
||||
fn search_limit(query: &BTreeMap<String, String>) -> usize {
|
||||
query
|
||||
.get("limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 50)
|
||||
}
|
||||
|
||||
fn exports_limit(query: &BTreeMap<String, String>) -> usize {
|
||||
query
|
||||
.get("limit")
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(50)
|
||||
.clamp(1, 200)
|
||||
}
|
||||
|
||||
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
|
||||
let mut filter = ObjectFilter::default();
|
||||
if let Some(value) = query.get("type") {
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::new(400, "type filter must not be empty"));
|
||||
}
|
||||
// Accept projection-vocabulary aliases so both `mft` and `manifest`
|
||||
// (etc.) match the URI-derived report stream types.
|
||||
let lower = value.to_ascii_lowercase();
|
||||
let normalized = match lower.as_str() {
|
||||
"mft" => "manifest",
|
||||
"cer" => "certificate",
|
||||
"asa" => "aspa",
|
||||
_ => lower.as_str(),
|
||||
};
|
||||
filter.object_type = Some(normalized.to_string());
|
||||
}
|
||||
if let Some(value) = query.get("result") {
|
||||
if !matches!(value.to_ascii_lowercase().as_str(), "ok" | "error" | "skipped") {
|
||||
return Err(ApiError::new(400, format!("invalid result filter: {value}")));
|
||||
}
|
||||
filter.result = Some(value.clone());
|
||||
}
|
||||
if let Some(value) = query.get("rejected") {
|
||||
filter.rejected = Some(match value.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => {
|
||||
return Err(ApiError::new(
|
||||
400,
|
||||
format!("invalid rejected filter: {value}"),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(value) = query.get("reason") {
|
||||
filter.reason = Some(value.to_lowercase());
|
||||
}
|
||||
if let Some(value) = query.get("q") {
|
||||
filter.query = Some(value.to_lowercase());
|
||||
}
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
|
||||
query.get("cursor").map(String::as_str)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user