20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
parent
a77982deaa
commit
a5902c0a5e
@ -7,9 +7,10 @@ use std::process::Command;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use rpki::blob_store::ExternalRepoBytesDb;
|
use rpki::blob_store::ExternalRepoBytesDb;
|
||||||
|
use rpki::query::report_stream::{ObjectFilter, ObjectScope};
|
||||||
use rpki::query_db::{
|
use rpki::query_db::{
|
||||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, QueryDb, QueryDbError,
|
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
||||||
ValidationExplainRecord,
|
QueryDbError, ValidationExplainRecord,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@ -544,6 +545,18 @@ fn route_request(
|
|||||||
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
return Ok(json!({"data":{"service":"rpki_query_service","version":1}}));
|
||||||
}
|
}
|
||||||
match segments.as_slice() {
|
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),
|
["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None),
|
||||||
["latest_run"] => {
|
["latest_run"] => {
|
||||||
let run_id = db
|
let run_id = db
|
||||||
@ -630,8 +643,15 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "repos", repo_id, "objects"] => {
|
["runs", raw_run_id, "repos", repo_id, "objects"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
|
let filter = object_filter_from_query(&query)?;
|
||||||
page_response(
|
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),
|
Some(run_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -669,18 +689,63 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
|
["runs", raw_run_id, "publication-points", pp_id, "objects"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
|
let filter = object_filter_from_query(&query)?;
|
||||||
page_response(
|
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),
|
Some(run_id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
["runs", raw_run_id, "objects"] => {
|
["runs", raw_run_id, "objects"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
|
let filter = object_filter_from_query(&query)?;
|
||||||
page_response(
|
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),
|
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"] => {
|
["runs", raw_run_id, "objects", "by-uri"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
let uri = query
|
let uri = query
|
||||||
@ -792,6 +857,18 @@ fn route_request(
|
|||||||
offset_cursor(&query),
|
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] => {
|
["runs", raw_run_id, "exports", job_id] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
let job = get_export_job(db, export_jobs, &run_id, job_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)?)
|
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(
|
fn run_export_job(
|
||||||
db: &QueryDb,
|
db: &QueryDb,
|
||||||
repo_bytes: &ExternalRepoBytesDb,
|
repo_bytes: &ExternalRepoBytesDb,
|
||||||
@ -1575,6 +1670,103 @@ fn projection_for_explain(
|
|||||||
Ok(Some(projection))
|
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(
|
fn global_projection_for_hash(
|
||||||
db: &QueryDb,
|
db: &QueryDb,
|
||||||
repo_bytes: Option<&ExternalRepoBytesDb>,
|
repo_bytes: Option<&ExternalRepoBytesDb>,
|
||||||
@ -1878,6 +2070,66 @@ fn limit(query: &BTreeMap<String, String>) -> usize {
|
|||||||
.clamp(1, 1000)
|
.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> {
|
fn cursor(query: &BTreeMap<String, String>) -> Option<&str> {
|
||||||
query.get("cursor").map(String::as_str)
|
query.get("cursor").map(String::as_str)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,68 @@ pub enum ObjectLookup {
|
|||||||
Sha256(String),
|
Sha256(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional predicates applied while streaming report objects. `reason` and
|
||||||
|
/// `query` must be pre-lowercased by the caller; they are matched as
|
||||||
|
/// case-insensitive substrings. The other fields are exact matches.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ObjectFilter {
|
||||||
|
pub object_type: Option<String>,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub rejected: Option<bool>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub repo_id: Option<String>,
|
||||||
|
pub pp_id: Option<String>,
|
||||||
|
pub issues_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectFilter {
|
||||||
|
pub fn matches(&self, object: &ObjectInstanceRecord) -> bool {
|
||||||
|
if self.issues_only && !(object.rejected || object.result == "error") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(rejected) = self.rejected
|
||||||
|
&& object.rejected != rejected
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(result) = self.result.as_deref()
|
||||||
|
&& !object.result.eq_ignore_ascii_case(result)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(object_type) = self.object_type.as_deref()
|
||||||
|
&& !object.object_type.eq_ignore_ascii_case(object_type)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(repo_id) = self.repo_id.as_deref()
|
||||||
|
&& object.repo_id != repo_id
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(pp_id) = self.pp_id.as_deref()
|
||||||
|
&& object.pp_id != pp_id
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(reason) = self.reason.as_deref() {
|
||||||
|
let Some(reject_reason) = object.reject_reason.as_deref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !reject_reason.to_lowercase().contains(reason) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(query) = self.query.as_deref()
|
||||||
|
&& !object.uri.to_lowercase().contains(query)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ObjectResolutionMeta {
|
pub struct ObjectResolutionMeta {
|
||||||
@ -81,13 +143,37 @@ pub fn list_report_objects(
|
|||||||
scope: ObjectScope,
|
scope: ObjectScope,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
|
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||||
|
list_report_objects_filtered(
|
||||||
|
report_path,
|
||||||
|
run_id,
|
||||||
|
scope,
|
||||||
|
&ObjectFilter::default(),
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_report_objects_filtered(
|
||||||
|
report_path: &Path,
|
||||||
|
run_id: &str,
|
||||||
|
scope: ObjectScope,
|
||||||
|
filter: &ObjectFilter,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&str>,
|
||||||
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||||
let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?;
|
let (start_pp_index, start_object_index) = parse_object_cursor(cursor)?;
|
||||||
let file = File::open(report_path)?;
|
let file = File::open(report_path)?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
let mut deserializer = serde_json::Deserializer::from_reader(reader);
|
let mut deserializer = serde_json::Deserializer::from_reader(reader);
|
||||||
let mut state =
|
let mut state = ObjectScanState::new_list(
|
||||||
ObjectScanState::new_list(run_id, scope, limit, start_pp_index, start_object_index);
|
run_id,
|
||||||
|
scope,
|
||||||
|
filter.clone(),
|
||||||
|
limit,
|
||||||
|
start_pp_index,
|
||||||
|
start_object_index,
|
||||||
|
);
|
||||||
ObjectScanSeed { state: &mut state }
|
ObjectScanSeed { state: &mut state }
|
||||||
.deserialize(&mut deserializer)
|
.deserialize(&mut deserializer)
|
||||||
.map_err(QueryDbError::from)?;
|
.map_err(QueryDbError::from)?;
|
||||||
@ -530,6 +616,7 @@ struct ObjectScanState<'a> {
|
|||||||
run_id: &'a str,
|
run_id: &'a str,
|
||||||
scope: ObjectScope,
|
scope: ObjectScope,
|
||||||
mode: ObjectScanMode,
|
mode: ObjectScanMode,
|
||||||
|
filter: ObjectFilter,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
start_pp_index: u64,
|
start_pp_index: u64,
|
||||||
start_object_index: u64,
|
start_object_index: u64,
|
||||||
@ -544,6 +631,7 @@ impl<'a> ObjectScanState<'a> {
|
|||||||
fn new_list(
|
fn new_list(
|
||||||
run_id: &'a str,
|
run_id: &'a str,
|
||||||
scope: ObjectScope,
|
scope: ObjectScope,
|
||||||
|
filter: ObjectFilter,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
start_pp_index: u64,
|
start_pp_index: u64,
|
||||||
start_object_index: u64,
|
start_object_index: u64,
|
||||||
@ -552,6 +640,7 @@ impl<'a> ObjectScanState<'a> {
|
|||||||
run_id,
|
run_id,
|
||||||
scope,
|
scope,
|
||||||
mode: ObjectScanMode::List,
|
mode: ObjectScanMode::List,
|
||||||
|
filter,
|
||||||
limit,
|
limit,
|
||||||
start_pp_index,
|
start_pp_index,
|
||||||
start_object_index,
|
start_object_index,
|
||||||
@ -568,6 +657,7 @@ impl<'a> ObjectScanState<'a> {
|
|||||||
run_id,
|
run_id,
|
||||||
scope,
|
scope,
|
||||||
mode: ObjectScanMode::Lookup(lookup),
|
mode: ObjectScanMode::Lookup(lookup),
|
||||||
|
filter: ObjectFilter::default(),
|
||||||
limit: 1,
|
limit: 1,
|
||||||
start_pp_index: 0,
|
start_pp_index: 0,
|
||||||
start_object_index: 0,
|
start_object_index: 0,
|
||||||
@ -682,7 +772,8 @@ fn process_raw_publication_point(
|
|||||||
let after_cursor = pp_index > state.start_pp_index
|
let after_cursor = pp_index > state.start_pp_index
|
||||||
|| (pp_index == state.start_pp_index
|
|| (pp_index == state.start_pp_index
|
||||||
&& object_index >= state.start_object_index);
|
&& object_index >= state.start_object_index);
|
||||||
if after_cursor && state.data.len() < state.limit {
|
if after_cursor && state.data.len() < state.limit && state.filter.matches(&record)
|
||||||
|
{
|
||||||
state.data.push(record);
|
state.data.push(record);
|
||||||
if state.data.len() == state.limit {
|
if state.data.len() == state.limit {
|
||||||
state.next_cursor = Some(object_cursor(pp_index, object_index + 1));
|
state.next_cursor = Some(object_cursor(pp_index, object_index + 1));
|
||||||
|
|||||||
137
src/query_db.rs
137
src/query_db.rs
@ -473,6 +473,31 @@ impl QueryDb {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_objects_filtered(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
scope: ObjectScope,
|
||||||
|
filter: &report_stream::ObjectFilter,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
) -> QueryDbResult<QueryPage<ObjectInstanceRecord>> {
|
||||||
|
let Some(report_path) = self.report_path_for_run(run_id)? else {
|
||||||
|
return Ok(QueryPage {
|
||||||
|
data: Vec::new(),
|
||||||
|
next_cursor: None,
|
||||||
|
limit: limit.clamp(1, 1000),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
report_stream::list_report_objects_filtered(
|
||||||
|
&report_path,
|
||||||
|
run_id,
|
||||||
|
scope,
|
||||||
|
filter,
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_object_by_instance_id(
|
pub fn get_object_by_instance_id(
|
||||||
&self,
|
&self,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
@ -628,6 +653,118 @@ impl QueryDb {
|
|||||||
self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes())
|
self.get_json_cf(CF_OBJECTS_BY_HASH, object_hash_key(sha256).as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn search_object_projections_by_hash_prefix(
|
||||||
|
&self,
|
||||||
|
sha256_prefix: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> QueryDbResult<Vec<crate::object_projection::ObjectProjectionRecord>> {
|
||||||
|
let cf = self.cf(CF_OBJECTS_BY_HASH)?;
|
||||||
|
let prefix = format!("objhash/{sha256_prefix}");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||||
|
for item in self.db.iterator_cf(cf, mode) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(serde_json::from_slice(&value)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cached_object_by_sha256(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
sha256: &str,
|
||||||
|
) -> QueryDbResult<Option<ObjectInstanceRecord>> {
|
||||||
|
let mut cursor = None;
|
||||||
|
loop {
|
||||||
|
let page: QueryPage<ObjectInstanceRecord> = self.list_json_by_prefix(
|
||||||
|
CF_OBJECT_INSTANCES,
|
||||||
|
&format!("objinst/{run_id}/"),
|
||||||
|
1000,
|
||||||
|
cursor.as_deref(),
|
||||||
|
)?;
|
||||||
|
if let Some(found) = page
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| item.sha256.eq_ignore_ascii_case(sha256))
|
||||||
|
{
|
||||||
|
return Ok(Some(found));
|
||||||
|
}
|
||||||
|
let Some(next_cursor) = page.next_cursor else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cursor = Some(next_cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_repos(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
query_lower: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> QueryDbResult<Vec<RepositoryRecord>> {
|
||||||
|
let cf = self.cf(CF_REPOS)?;
|
||||||
|
let prefix = format!("repo/{run_id}/");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||||
|
for item in self.db.iterator_cf(cf, mode) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let repo: RepositoryRecord = serde_json::from_slice(&value)?;
|
||||||
|
if repo.host.to_lowercase().contains(query_lower)
|
||||||
|
|| repo.uri.to_lowercase().contains(query_lower)
|
||||||
|
{
|
||||||
|
out.push(repo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_publication_points(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
query_lower: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> QueryDbResult<Vec<PublicationPointRecord>> {
|
||||||
|
let cf = self.cf(CF_PUBLICATION_POINTS)?;
|
||||||
|
let prefix = format!("pp/{run_id}/");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mode = IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward);
|
||||||
|
for item in self.db.iterator_cf(cf, mode) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
if !String::from_utf8_lossy(&key).starts_with(&prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let pp: PublicationPointRecord = serde_json::from_slice(&value)?;
|
||||||
|
let uri_matches = [
|
||||||
|
pp.manifest_rsync_uri.as_deref(),
|
||||||
|
pp.publication_point_rsync_uri.as_deref(),
|
||||||
|
pp.rrdp_notification_uri.as_deref(),
|
||||||
|
pp.rsync_base_uri.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|uri| uri.to_lowercase().contains(query_lower));
|
||||||
|
if uri_matches {
|
||||||
|
out.push(pp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_stat(
|
pub fn get_stat(
|
||||||
&self,
|
&self,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
|
|||||||
@ -1,81 +1,111 @@
|
|||||||
# RPKI Explorer
|
# RPKI Explorer
|
||||||
|
|
||||||
RPKI Explorer is the SPA frontend for browsing ours RP `rpki_query_service` outputs.
|
RPKI Explorer is the web UI for browsing the outputs of the ours RP
|
||||||
|
`rpki_query_service` — runs, repositories, publication points, objects,
|
||||||
|
validation issues and evidence exports. It is a read-only query layer: it does
|
||||||
|
not change validation logic or RP state.
|
||||||
|
|
||||||
## Scope
|
## Features
|
||||||
|
|
||||||
Current MVP covers:
|
- **Overview** — latest-run health at a glance: VRP/ASPA/object/PP counts,
|
||||||
|
validation result and object type charts, top repositories, top reject
|
||||||
|
reasons, all with drill-down links.
|
||||||
|
- **Repositories** — paginated repository list, repository detail with sync
|
||||||
|
phases/terminal states, publication points and scoped object browsing.
|
||||||
|
- **Publication Points** — global PP list and PP detail (URIs, sync state,
|
||||||
|
thisUpdate/nextUpdate, objects).
|
||||||
|
- **Objects** — global object browser with **server-side filters**
|
||||||
|
(`type`, `result`, `rejected`, `q` URI substring) and cursor paging.
|
||||||
|
- **Object detail** — structured parsed projections (ROA prefixes, ASPA
|
||||||
|
providers, manifest file list, CRL revocations, certificate fields),
|
||||||
|
validation summary, on-demand *explain* (audit projection, non-authoritative),
|
||||||
|
chain edges, raw DER download, object-set export.
|
||||||
|
- **Validation** — result distribution, reject-reason distribution with
|
||||||
|
click-to-filter, paginated issue list (`/issues`).
|
||||||
|
- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex
|
||||||
|
prefix), repository host/URI substring, PP URI substring.
|
||||||
|
- **Exports** — export job list with live polling and tarball download.
|
||||||
|
- **Runs** — indexed run history; pin any run as the browsing context via the
|
||||||
|
run selector in the top bar (`?run=` URL param, deep-linkable everywhere).
|
||||||
|
- **API** — service health and endpoint reference.
|
||||||
|
|
||||||
- Overview dashboard from latest run, stats, validation reasons, and top repositories.
|
## Backend requirements
|
||||||
- Repository -> publication point -> object lazy browser.
|
|
||||||
- Object detail with live object record, validation summary, lazy parsed/chain/list tabs, and validation explain.
|
|
||||||
- Exact URI lookup, raw download action, and object / publication point export actions.
|
|
||||||
|
|
||||||
Known backend-dependent limits:
|
`rpki_query_service` (this repository, `src/bin/rpki_query_service.rs`) with a
|
||||||
|
query-db built by `rpki_query_indexer`. Some features need extra service flags:
|
||||||
|
|
||||||
- Parsed projection, raw download, and export success require `rpki_query_service --repo-bytes-db <path>`.
|
| Feature | Requirement |
|
||||||
- Without repo bytes, the UI shows explicit unavailable/error states instead of fabricating data.
|
| --- | --- |
|
||||||
- VRP IP/prefix lookup is not part of this MVP and is tracked separately.
|
| Parsed projections, raw download, exports | `--repo-bytes-db <path>` |
|
||||||
- Query service does not provide CORS headers; use a same-origin proxy.
|
| New runs appear automatically | `--watch-run-root <path>` |
|
||||||
|
|
||||||
|
The service has **no CORS headers and no authentication** — the UI must be
|
||||||
|
served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse
|
||||||
|
proxy). VRP IP/prefix/ASN lookup is not available yet (backend feature #070);
|
||||||
|
the search page says so explicitly when such a query is detected.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Start query service first:
|
Start the query service (example with a local query-db):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rpki_2/rpki/target/llvm-cov-target/debug/rpki_query_service \
|
rpki_2/rpki/target/debug/rpki_query_service \
|
||||||
--query-db .scratch/rpki_explorer_m3_api/query-db \
|
--query-db /path/to/query-db \
|
||||||
--listen 127.0.0.1:19571
|
--repo-bytes-db /path/to/repo-bytes.db \
|
||||||
|
--listen 127.0.0.1:9557
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the Vite dev server with a proxy:
|
Start the Vite dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rpki_2/rpki/ui/rpki-explorer
|
cd rpki_2/rpki/ui/rpki-explorer
|
||||||
npm install
|
npm install
|
||||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run dev -- --port 5173
|
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The Vite dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`; default target is `http://127.0.0.1:9557`.
|
The dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`
|
||||||
|
(default `http://127.0.0.1:9557`) and serves the UI on `http://127.0.0.1:5173`.
|
||||||
|
|
||||||
## Production Build Preview
|
## Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rpki_2/rpki/ui/rpki-explorer
|
|
||||||
npm run build
|
npm run build
|
||||||
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run preview -- --port 4173
|
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:9557 npm run preview # smoke-test the build
|
||||||
```
|
```
|
||||||
|
|
||||||
For real deployment, serve `dist/` through a static server or reverse proxy and route `/api/v1/*` to `rpki_query_service` on the same origin.
|
For real deployments serve `dist/` with any static server / reverse proxy and
|
||||||
|
forward `/api/v1/*` to `rpki_query_service` on the same origin, e.g. nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/v1/ { proxy_pass http://127.0.0.1:9557; }
|
||||||
|
location / { root /srv/rpki-explorer/dist; try_files $uri /index.html; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`try_files ... /index.html` (SPA fallback) is required because routing is
|
||||||
|
client-side.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck
|
npm run typecheck # tsc project references
|
||||||
npm run lint
|
npm run lint # eslint
|
||||||
npm run build
|
npm test # vitest unit tests (lib + api contract)
|
||||||
npm audit --audit-level=high
|
npm run build # production build
|
||||||
npm run test:e2e
|
npm run test:e2e # Playwright, fully mocked backend (no service needed)
|
||||||
```
|
```
|
||||||
|
|
||||||
Playwright is intentionally configured with `workers: 1`. The current query service object endpoints use lazy `report.json` scans, so parallel browser tests can create artificial backend contention and false failures.
|
E2E tests intercept every `/api/v1/**` request with a fixture-backed mock
|
||||||
|
(`tests/e2e/mockApi.ts`), so they are deterministic and need no running query
|
||||||
|
service. They start the Vite dev server automatically (`workers: 1`).
|
||||||
|
|
||||||
## Screenshot Evidence
|
## Directory layout
|
||||||
|
|
||||||
Milestone screenshots are stored under:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
specs/develop/20260617/m*_playwright/
|
src/api/ fetch client, zod schemas, typed endpoint functions
|
||||||
```
|
src/app/ providers, router, react-query client
|
||||||
|
src/components/ shell, data table, pills, pager, tabs, object table, …
|
||||||
## Directory Layout
|
src/lib/ formatting, cursor pager state machine, run context, guards
|
||||||
|
src/pages/ one file per route
|
||||||
```text
|
src/styles/ design tokens + base/shell/components/pages CSS
|
||||||
src/api/ query-service client and TypeScript records
|
tests/e2e/ Playwright specs + fixture-backed API mock
|
||||||
src/app/ app shell and top-level navigation state
|
|
||||||
src/components/layout/ sidebar/topbar shell
|
|
||||||
src/features/overview/ latest run overview dashboard
|
|
||||||
src/features/repositories repo -> PP -> object browser
|
|
||||||
src/features/object-detail live object detail and workflows
|
|
||||||
src/styles/globals.css MVP visual system
|
|
||||||
```
|
```
|
||||||
|
|||||||
517
ui/rpki-explorer/package-lock.json
generated
517
ui/rpki-explorer/package-lock.json
generated
@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "rpki-explorer",
|
"name": "rpki-explorer",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rpki-explorer",
|
"name": "rpki-explorer",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
|
||||||
"@xyflow/react": "^12.8.2",
|
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@ -22,8 +19,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@playwright/test": "^1.53.1",
|
"@playwright/test": "^1.53.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@types/node": "^24.0.3",
|
"@types/node": "^24.0.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
@ -38,50 +33,6 @@
|
|||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
|
||||||
"version": "4.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
|
||||||
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
|
||||||
"js-tokens": "^4.0.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@ -747,142 +698,6 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-table": {
|
|
||||||
"version": "8.21.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
|
||||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/table-core": "8.21.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8",
|
|
||||||
"react-dom": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/react-virtual": {
|
|
||||||
"version": "3.14.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
|
|
||||||
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/virtual-core": "3.17.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/table-core": {
|
|
||||||
"version": "8.21.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
|
||||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/virtual-core": {
|
|
||||||
"version": "3.17.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
|
|
||||||
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/dom": {
|
|
||||||
"version": "10.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.10.4",
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/aria-query": "^5.0.1",
|
|
||||||
"aria-query": "5.3.0",
|
|
||||||
"dom-accessibility-api": "^0.5.9",
|
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"picocolors": "1.1.1",
|
|
||||||
"pretty-format": "^27.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/jest-dom": {
|
|
||||||
"version": "6.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
|
||||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@adobe/css-tools": "^4.4.0",
|
|
||||||
"aria-query": "^5.0.0",
|
|
||||||
"css.escape": "^1.5.1",
|
|
||||||
"dom-accessibility-api": "^0.6.3",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"redent": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14",
|
|
||||||
"npm": ">=6",
|
|
||||||
"yarn": ">=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/react": {
|
|
||||||
"version": "16.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
|
||||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": "^10.0.0",
|
|
||||||
"@types/react": "^18.0.0 || ^19.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@ -894,14 +709,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/aria-query": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@ -925,15 +732,6 @@
|
|||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-drag": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-ease": {
|
"node_modules/@types/d3-ease": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
@ -964,12 +762,6 @@
|
|||||||
"@types/d3-time": "*"
|
"@types/d3-time": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-selection": {
|
|
||||||
"version": "3.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-shape": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
@ -991,25 +783,6 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-transition": {
|
|
||||||
"version": "3.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
|
||||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-zoom": {
|
|
||||||
"version": "3.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
|
||||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-interpolate": "*",
|
|
||||||
"@types/d3-selection": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@ -1055,7 +828,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@ -1501,48 +1274,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
|
||||||
"version": "12.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
|
|
||||||
"integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@xyflow/system": "0.0.77",
|
|
||||||
"classcat": "^5.0.3",
|
|
||||||
"zustand": "^4.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=17",
|
|
||||||
"@types/react-dom": ">=17",
|
|
||||||
"react": ">=17",
|
|
||||||
"react-dom": ">=17"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xyflow/system": {
|
|
||||||
"version": "0.0.77",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
|
|
||||||
"integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-drag": "^3.0.7",
|
|
||||||
"@types/d3-interpolate": "^3.0.4",
|
|
||||||
"@types/d3-selection": "^3.0.10",
|
|
||||||
"@types/d3-transition": "^3.0.8",
|
|
||||||
"@types/d3-zoom": "^3.0.8",
|
|
||||||
"d3-drag": "^3.0.0",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-selection": "^3.0.0",
|
|
||||||
"d3-zoom": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@ -1583,17 +1314,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@ -1617,16 +1337,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-query": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@ -1692,12 +1402,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/classcat": {
|
|
||||||
"version": "5.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
|
||||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@ -1769,13 +1473,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/css.escape": {
|
|
||||||
"version": "1.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
|
||||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@ -1804,28 +1501,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-dispatch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-drag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-selection": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
"node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
@ -1881,15 +1556,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-selection": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
@ -1935,41 +1601,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-transition": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-ease": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"d3-selection": "2 - 3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-zoom": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "2 - 3",
|
|
||||||
"d3-transition": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@ -2001,16 +1632,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dequal": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -2021,14 +1642,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
|
||||||
"version": "0.5.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
@ -2450,16 +2063,6 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/indent-string": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
@ -2499,14 +2102,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
@ -2880,17 +2475,6 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"lz-string": "bin/bin.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -2901,16 +2485,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/min-indent": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@ -3152,36 +2726,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pretty-format": {
|
|
||||||
"version": "27.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1",
|
|
||||||
"ansi-styles": "^5.0.0",
|
|
||||||
"react-is": "^17.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -3311,20 +2855,6 @@
|
|||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redent": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"indent-string": "^4.0.0",
|
|
||||||
"strip-indent": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
@ -3456,19 +2986,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/strip-indent": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"min-indent": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@ -3912,34 +3429,6 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zustand": {
|
|
||||||
"version": "4.5.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
|
||||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"use-sync-external-store": "^1.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.7.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": ">=16.8",
|
|
||||||
"immer": ">=9.0.6",
|
|
||||||
"react": ">=16.8"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"immer": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rpki-explorer",
|
"name": "rpki-explorer",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -9,13 +9,11 @@
|
|||||||
"typecheck": "tsc -b --pretty false",
|
"typecheck": "tsc -b --pretty false",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
|
||||||
"@xyflow/react": "^12.8.2",
|
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@ -26,8 +24,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@playwright/test": "^1.53.1",
|
"@playwright/test": "^1.53.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@types/node": "^24.0.3",
|
"@types/node": "^24.0.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
|||||||
@ -3,18 +3,24 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
outputDir: "./test-results",
|
outputDir: "./test-results",
|
||||||
fullyParallel: true,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: [["list"], ["html", { open: "never" }]],
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
screenshot: "only-on-failure"
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev -- --port 5173 --strictPort",
|
||||||
|
url: "http://127.0.0.1:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
use: { ...devices["Desktop Chrome"] }
|
use: { ...devices["Desktop Chrome"] },
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
102
ui/rpki-explorer/src/api/client.test.ts
Normal file
102
ui/rpki-explorer/src/api/client.test.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { withQuery } from "./client";
|
||||||
|
import {
|
||||||
|
envelopeSchema,
|
||||||
|
objectInstanceRecordSchema,
|
||||||
|
runRecordSchema,
|
||||||
|
searchResultSchema,
|
||||||
|
} from "./schemas";
|
||||||
|
|
||||||
|
describe("withQuery", () => {
|
||||||
|
it("builds a query string skipping empty values", () => {
|
||||||
|
expect(
|
||||||
|
withQuery("/api/v1/runs/latest/objects", {
|
||||||
|
limit: 50,
|
||||||
|
cursor: null,
|
||||||
|
type: "roa",
|
||||||
|
rejected: false,
|
||||||
|
q: "",
|
||||||
|
reason: undefined,
|
||||||
|
}),
|
||||||
|
).toBe("/api/v1/runs/latest/objects?limit=50&type=roa&rejected=false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL-encodes values", () => {
|
||||||
|
expect(withQuery("/x", { uri: "rsync://a/b.cer" })).toBe(
|
||||||
|
"/x?uri=rsync%3A%2F%2Fa%2Fb.cer",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the bare path without params", () => {
|
||||||
|
expect(withQuery("/x")).toBe("/x");
|
||||||
|
expect(withQuery("/x", {})).toBe("/x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("envelope + record schemas", () => {
|
||||||
|
const objectInstance = {
|
||||||
|
objectInstanceId: "obj-1",
|
||||||
|
uri: "rsync://repo.example/ca/roa.roa",
|
||||||
|
sha256: "ab".repeat(32),
|
||||||
|
objectType: "roa",
|
||||||
|
result: "ok",
|
||||||
|
repoId: "repo-1",
|
||||||
|
ppId: "pp-1",
|
||||||
|
sourceSection: "fresh",
|
||||||
|
rejected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("parses a list envelope and exposes the cursor", () => {
|
||||||
|
const parsed = envelopeSchema(objectInstanceRecordSchema.array()).parse({
|
||||||
|
data: [objectInstance],
|
||||||
|
page: { nextCursor: "r1:0:50", limit: 50 },
|
||||||
|
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||||
|
});
|
||||||
|
expect(parsed.data).toHaveLength(1);
|
||||||
|
expect(parsed.page?.nextCursor).toBe("r1:0:50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a null meta.runId (healthz and other run-less endpoints)", () => {
|
||||||
|
const parsed = envelopeSchema(z.object({ status: z.string() })).parse({
|
||||||
|
data: { status: "ok" },
|
||||||
|
page: null,
|
||||||
|
meta: { runId: null, schemaVersion: 1 },
|
||||||
|
});
|
||||||
|
expect(parsed.data.status).toBe("ok");
|
||||||
|
expect(parsed.meta?.runId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unknown fields (passthrough) for forward compatibility", () => {
|
||||||
|
const parsed = objectInstanceRecordSchema.parse({
|
||||||
|
...objectInstance,
|
||||||
|
futureField: { nested: true },
|
||||||
|
});
|
||||||
|
expect((parsed as Record<string, unknown>).futureField).toEqual({ nested: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects records missing required identity fields", () => {
|
||||||
|
expect(() =>
|
||||||
|
objectInstanceRecordSchema.parse({ uri: "rsync://x" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses run records with partial counts", () => {
|
||||||
|
const run = runRecordSchema.parse({
|
||||||
|
runId: "run_0002",
|
||||||
|
runSeq: 2,
|
||||||
|
counts: { objects: 10 },
|
||||||
|
});
|
||||||
|
expect(run.counts?.objects).toBe(10);
|
||||||
|
expect(run.counts?.vrps).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the unified search result", () => {
|
||||||
|
const result = searchResultSchema.parse({
|
||||||
|
objects: [objectInstance],
|
||||||
|
repos: [],
|
||||||
|
publicationPoints: [],
|
||||||
|
});
|
||||||
|
expect(result.objects[0]?.objectInstanceId).toBe("obj-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,101 +1,101 @@
|
|||||||
export interface ApiEnvelope<T> {
|
/**
|
||||||
data: T;
|
* Low-level fetch helpers for the rpki_query_service HTTP API.
|
||||||
page: { nextCursor: string | null; limit: number } | null;
|
*
|
||||||
meta: { runId: string | null; schemaVersion: number };
|
* All JSON endpoints share the envelope shape:
|
||||||
}
|
* { "data": ..., "page": { "nextCursor": string | null, "limit": number } | null,
|
||||||
|
* "meta": { "runId": string, "schemaVersion": number } }
|
||||||
|
* Errors are `{ "error": string }` with a non-2xx status.
|
||||||
|
*/
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
readonly status: number;
|
||||||
message: string,
|
|
||||||
readonly status: number
|
constructor(status: number, message: string) {
|
||||||
) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JsonQuery = Record<string, string | number | boolean | null | undefined>;
|
export type QueryParams = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
export function withQuery(path: string, query?: JsonQuery): string {
|
/** Build a URL with a query string, skipping empty values. */
|
||||||
if (!query) {
|
export function withQuery(path: string, params?: QueryParams): string {
|
||||||
return path;
|
if (!params) return path;
|
||||||
}
|
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null || value === "") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
search.set(key, String(value));
|
search.set(key, String(value));
|
||||||
}
|
}
|
||||||
const queryString = search.toString();
|
const qs = search.toString();
|
||||||
return queryString ? `${path}?${queryString}` : path;
|
return qs ? `${path}?${qs}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJson<T>(path: string, query?: JsonQuery): Promise<ApiEnvelope<T>> {
|
async function parseErrorBody(res: Response): Promise<string> {
|
||||||
const response = await fetch(withQuery(path, query));
|
try {
|
||||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
const body: unknown = await res.json();
|
||||||
if (!response.ok) {
|
if (
|
||||||
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
|
body !== null &&
|
||||||
}
|
typeof body === "object" &&
|
||||||
return normalizeEnvelope<T>(body);
|
"error" in body &&
|
||||||
}
|
typeof (body as { error: unknown }).error === "string"
|
||||||
|
) {
|
||||||
export async function postJson<T>(path: string, body?: unknown): Promise<ApiEnvelope<T>> {
|
return (body as { error: string }).error;
|
||||||
const response = await fetch(path, {
|
|
||||||
body: JSON.stringify(body ?? {}),
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
method: "POST"
|
|
||||||
});
|
|
||||||
const responseBody = (await response.json().catch(() => ({}))) as { error?: string };
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ApiError(responseBody.error ?? `Request failed: ${response.status}`, response.status);
|
|
||||||
}
|
|
||||||
return normalizeEnvelope<T>(responseBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBinary(path: string): Promise<Blob> {
|
|
||||||
const response = await fetch(path);
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
||||||
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
|
|
||||||
}
|
|
||||||
return response.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeEnvelope<T>(value: unknown): ApiEnvelope<T> {
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
return {
|
|
||||||
data: value as T,
|
|
||||||
page: null,
|
|
||||||
meta: { runId: null, schemaVersion: 1 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const record = value as {
|
|
||||||
data?: T;
|
|
||||||
page?: { nextCursor?: string | null; limit?: number } | null;
|
|
||||||
meta?: { runId?: string | null; schemaVersion?: number } | null;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
data: record.data as T,
|
|
||||||
page: record.page
|
|
||||||
? {
|
|
||||||
nextCursor: record.page.nextCursor ?? null,
|
|
||||||
limit: record.page.limit ?? 0
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
meta: {
|
|
||||||
runId: record.meta?.runId ?? null,
|
|
||||||
schemaVersion: record.meta?.schemaVersion ?? 1
|
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
|
// fall through to status text
|
||||||
|
}
|
||||||
|
return `HTTP ${res.status} ${res.statusText}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeApiError(error: unknown): Error {
|
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
||||||
if (error instanceof ApiError) {
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
return error;
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(path, init);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (!res.ok) {
|
||||||
return error;
|
throw new ApiError(res.status, await parseErrorBody(res));
|
||||||
}
|
}
|
||||||
return new Error("Unknown API error");
|
return (await res.json()) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch binary content (raw object bytes, export tarballs). */
|
||||||
|
export async function apiFetchBlob(path: string): Promise<Blob> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(path);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(res.status, await parseErrorBody(res));
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST a JSON body, returning the parsed JSON response. */
|
||||||
|
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return apiFetch<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a browser download for a blob. */
|
||||||
|
export function saveBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,301 +0,0 @@
|
|||||||
import { getBinary, getJson, postJson } from "./client";
|
|
||||||
|
|
||||||
export interface ServiceInfo {
|
|
||||||
service: string;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
runId: string;
|
|
||||||
runSeq: number | null;
|
|
||||||
runDir: string;
|
|
||||||
validationTime: string | null;
|
|
||||||
syncMode: string | null;
|
|
||||||
startedAt: string | null;
|
|
||||||
finishedAt: string | null;
|
|
||||||
wallMs: number | null;
|
|
||||||
artifactPaths: Record<string, string>;
|
|
||||||
counts: {
|
|
||||||
publicationPoints: number;
|
|
||||||
objects: number;
|
|
||||||
freshObjects: number;
|
|
||||||
cachedObjects: number;
|
|
||||||
rejectedObjects: number;
|
|
||||||
freshRejectedObjects: number;
|
|
||||||
cachedRejectedObjects: number;
|
|
||||||
trustAnchors: number;
|
|
||||||
vrps: number;
|
|
||||||
aspas: number;
|
|
||||||
warnings: number;
|
|
||||||
};
|
|
||||||
indexStatus: string;
|
|
||||||
indexError: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepositoryRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
runId: string;
|
|
||||||
repoId: string;
|
|
||||||
uri: string;
|
|
||||||
host: string;
|
|
||||||
transport: string;
|
|
||||||
publicationPoints: number;
|
|
||||||
objects: number;
|
|
||||||
rejectedObjects: number;
|
|
||||||
downloadBytes: number | null;
|
|
||||||
syncDurationMsTotal: number;
|
|
||||||
phases: Record<string, number>;
|
|
||||||
terminalStates: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PublicationPointRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
runId: string;
|
|
||||||
ppId: string;
|
|
||||||
repoId: string;
|
|
||||||
nodeId: number | null;
|
|
||||||
parentNodeId: number | null;
|
|
||||||
rsyncBaseUri: string | null;
|
|
||||||
manifestRsyncUri: string | null;
|
|
||||||
publicationPointRsyncUri: string | null;
|
|
||||||
rrdpNotificationUri: string | null;
|
|
||||||
source: string | null;
|
|
||||||
repoSyncSource: string | null;
|
|
||||||
repoSyncPhase: string | null;
|
|
||||||
repoSyncDurationMs: number | null;
|
|
||||||
repoSyncError: string | null;
|
|
||||||
repoTerminalState: string | null;
|
|
||||||
thisUpdate: string | null;
|
|
||||||
nextUpdate: string | null;
|
|
||||||
verifiedAt: string | null;
|
|
||||||
objects: number;
|
|
||||||
rejectedObjects: number;
|
|
||||||
warnings: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectInstanceRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
runId: string;
|
|
||||||
objectInstanceId: string;
|
|
||||||
uri: string;
|
|
||||||
uriHash: string;
|
|
||||||
sha256: string;
|
|
||||||
objectType: string;
|
|
||||||
result: string;
|
|
||||||
detailSummary: string | null;
|
|
||||||
repoId: string;
|
|
||||||
ppId: string;
|
|
||||||
sourceSection: string;
|
|
||||||
rejected: boolean;
|
|
||||||
rejectReason: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectProjectionRecord {
|
|
||||||
schemaVersion?: number;
|
|
||||||
objectType?: string;
|
|
||||||
sha256?: string;
|
|
||||||
uri?: string;
|
|
||||||
parseStatus?: string;
|
|
||||||
errorSummary?: string | null;
|
|
||||||
projection?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationIssueRecord {
|
|
||||||
stage?: string;
|
|
||||||
severity?: string;
|
|
||||||
reasonCode?: string;
|
|
||||||
summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectValidationRecord {
|
|
||||||
objectInstanceId: string;
|
|
||||||
uri: string;
|
|
||||||
sha256: string;
|
|
||||||
objectType: string;
|
|
||||||
finalStatus: string;
|
|
||||||
auditResult: string;
|
|
||||||
detailSummary: string | null;
|
|
||||||
rejected: boolean;
|
|
||||||
rejectReason: string | null;
|
|
||||||
sourceSection: string;
|
|
||||||
explainAvailable: boolean;
|
|
||||||
authoritative: boolean;
|
|
||||||
fileValidation: {
|
|
||||||
status: string;
|
|
||||||
stage: string;
|
|
||||||
issues: ValidationIssueRecord[];
|
|
||||||
detailSummary: string | null;
|
|
||||||
};
|
|
||||||
chainValidation: {
|
|
||||||
status: string;
|
|
||||||
stage: string;
|
|
||||||
issues: ValidationIssueRecord[];
|
|
||||||
note?: string;
|
|
||||||
edgesPage?: { nextCursor: string | null; limit: number };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChainEdgeRecord {
|
|
||||||
relation: string;
|
|
||||||
fromUri: string;
|
|
||||||
toUri: string;
|
|
||||||
toObjectInstanceId: string | null;
|
|
||||||
toSha256: string | null;
|
|
||||||
status: string;
|
|
||||||
evidence: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationExplainRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
explainVersion: number;
|
|
||||||
runId: string;
|
|
||||||
objectInstanceId: string;
|
|
||||||
uri: string;
|
|
||||||
sha256: string;
|
|
||||||
objectType: string;
|
|
||||||
finalStatus: string;
|
|
||||||
auditResult: string;
|
|
||||||
detailSummary: string | null;
|
|
||||||
authoritative: boolean;
|
|
||||||
explainMode: string;
|
|
||||||
generatedAt: string;
|
|
||||||
parsevalidate: unknown;
|
|
||||||
chainvalidate: unknown;
|
|
||||||
chainEdges: ChainEdgeRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectUriIndexRecord {
|
|
||||||
runId: string;
|
|
||||||
uri: string;
|
|
||||||
sha256: string;
|
|
||||||
objectInstanceId: string;
|
|
||||||
repoId: string;
|
|
||||||
ppId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportJobRecord {
|
|
||||||
schemaVersion: number;
|
|
||||||
jobId: string;
|
|
||||||
runId: string;
|
|
||||||
scope: string;
|
|
||||||
repoId: string | null;
|
|
||||||
ppId: string | null;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
finishedAt: string | null;
|
|
||||||
outputPath: string | null;
|
|
||||||
objectCount: number;
|
|
||||||
bytesWritten: number;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CountsByKey = Record<string, number>;
|
|
||||||
|
|
||||||
export async function getServiceInfo() {
|
|
||||||
return getJson<ServiceInfo>("/api/v1");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLatestRun() {
|
|
||||||
return getJson<RunRecord>("/api/v1/latest_run");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRunSummary(runId: string) {
|
|
||||||
return getJson<Record<string, number>>(`/api/v1/runs/${runId}/summary`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStatsObjectTypes(runId: string) {
|
|
||||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/object-types`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStatsValidation(runId: string) {
|
|
||||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/validation`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStatsReasons(runId: string) {
|
|
||||||
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/reasons`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStatsDownloads(runId: string) {
|
|
||||||
return getJson<Record<string, unknown>>(`/api/v1/runs/${runId}/stats/downloads`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRepos(runId: string, limit = 5, cursor?: string | null) {
|
|
||||||
return getJson<RepositoryRecord[]>(`/api/v1/runs/${runId}/repos`, { limit, cursor: cursor ?? undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPublicationPointsForRepo(runId: string, repoId: string, limit = 50, cursor?: string | null) {
|
|
||||||
return getJson<PublicationPointRecord[]>(`/api/v1/runs/${runId}/repos/${repoId}/publication-points`, {
|
|
||||||
limit,
|
|
||||||
cursor: cursor ?? undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listObjectsForPublicationPoint(runId: string, ppId: string, limit = 50, cursor?: string | null) {
|
|
||||||
return getJson<ObjectInstanceRecord[]>(`/api/v1/runs/${runId}/publication-points/${ppId}/objects`, {
|
|
||||||
limit,
|
|
||||||
cursor: cursor ?? undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getObject(runId: string, objectInstanceId: string) {
|
|
||||||
return getJson<ObjectInstanceRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getObjectByUri(runId: string, uri: string) {
|
|
||||||
return getJson<ObjectUriIndexRecord>(`/api/v1/runs/${runId}/objects/by-uri`, { uri });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getObjectParsed(runId: string, objectInstanceId: string) {
|
|
||||||
return getJson<ObjectProjectionRecord | null>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getObjectValidation(runId: string, objectInstanceId: string) {
|
|
||||||
return getJson<ObjectValidationRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getObjectChain(runId: string, objectInstanceId: string) {
|
|
||||||
return getJson<ChainEdgeRecord[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/chain`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listManifestFiles(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
|
|
||||||
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/manifest-files`, {
|
|
||||||
limit,
|
|
||||||
cursor: cursor ?? undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRevokedCertificates(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
|
|
||||||
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/revoked-certs`, {
|
|
||||||
limit,
|
|
||||||
cursor: cursor ?? undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function explainObjectValidation(runId: string, objectInstanceId: string, forceRefresh = false) {
|
|
||||||
return postJson<ValidationExplainRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation/explain`, {
|
|
||||||
forceRefresh
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadRawObject(runId: string, objectInstanceId: string) {
|
|
||||||
return getBinary(`/api/v1/runs/${runId}/objects/${objectInstanceId}/raw`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createObjectSetExport(runId: string, objectInstanceIds: string[]) {
|
|
||||||
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
|
|
||||||
objectInstanceIds,
|
|
||||||
scope: "object_set"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPublicationPointExport(runId: string, ppId: string) {
|
|
||||||
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
|
|
||||||
ppId,
|
|
||||||
scope: "publication_point"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExportJob(runId: string, jobId: string) {
|
|
||||||
return getJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports/${jobId}`);
|
|
||||||
}
|
|
||||||
308
ui/rpki-explorer/src/api/schemas.ts
Normal file
308
ui/rpki-explorer/src/api/schemas.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Zod schemas describing the rpki_query_service API contract.
|
||||||
|
*
|
||||||
|
* Schemas are intentionally `.passthrough()` so additive backend changes do
|
||||||
|
* not break the UI; fields the UI depends on are typed strictly.
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Records */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const runCountsSchema = z
|
||||||
|
.object({
|
||||||
|
publicationPoints: z.number().optional(),
|
||||||
|
objects: z.number().optional(),
|
||||||
|
freshObjects: z.number().optional(),
|
||||||
|
cachedObjects: z.number().optional(),
|
||||||
|
rejectedObjects: z.number().optional(),
|
||||||
|
freshRejectedObjects: z.number().optional(),
|
||||||
|
cachedRejectedObjects: z.number().optional(),
|
||||||
|
trustAnchors: z.number().optional(),
|
||||||
|
vrps: z.number().optional(),
|
||||||
|
aspas: z.number().optional(),
|
||||||
|
warnings: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const runRecordSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.number().optional(),
|
||||||
|
runId: z.string(),
|
||||||
|
runSeq: z.number().nullish(),
|
||||||
|
runDir: z.string().optional(),
|
||||||
|
validationTime: z.string().nullish(),
|
||||||
|
syncMode: z.string().nullish(),
|
||||||
|
startedAt: z.string().nullish(),
|
||||||
|
finishedAt: z.string().nullish(),
|
||||||
|
wallMs: z.number().nullish(),
|
||||||
|
maxRssKb: z.number().nullish(),
|
||||||
|
artifactPaths: z.record(z.string()).optional(),
|
||||||
|
counts: runCountsSchema.optional(),
|
||||||
|
indexStatus: z.string().optional(),
|
||||||
|
indexError: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const repositoryRecordSchema = z
|
||||||
|
.object({
|
||||||
|
repoId: z.string(),
|
||||||
|
uri: z.string(),
|
||||||
|
host: z.string(),
|
||||||
|
transport: z.string().nullish(),
|
||||||
|
publicationPoints: z.number().nullish(),
|
||||||
|
objects: z.number().nullish(),
|
||||||
|
rejectedObjects: z.number().nullish(),
|
||||||
|
downloadBytes: z.number().nullish(),
|
||||||
|
syncDurationMsTotal: z.number().nullish(),
|
||||||
|
phases: z.record(z.number()).optional(),
|
||||||
|
terminalStates: z.record(z.number()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const publicationPointRecordSchema = z
|
||||||
|
.object({
|
||||||
|
ppId: z.string(),
|
||||||
|
repoId: z.string(),
|
||||||
|
nodeId: z.number().nullish(),
|
||||||
|
parentNodeId: z.number().nullish(),
|
||||||
|
rsyncBaseUri: z.string().nullish(),
|
||||||
|
manifestRsyncUri: z.string().nullish(),
|
||||||
|
publicationPointRsyncUri: z.string().nullish(),
|
||||||
|
rrdpNotificationUri: z.string().nullish(),
|
||||||
|
source: z.string().nullish(),
|
||||||
|
repoSyncSource: z.string().nullish(),
|
||||||
|
repoSyncPhase: z.string().nullish(),
|
||||||
|
repoSyncDurationMs: z.number().nullish(),
|
||||||
|
repoSyncError: z.string().nullish(),
|
||||||
|
repoTerminalState: z.string().nullish(),
|
||||||
|
thisUpdate: z.string().nullish(),
|
||||||
|
nextUpdate: z.string().nullish(),
|
||||||
|
verifiedAt: z.string().nullish(),
|
||||||
|
objects: z.number().nullish(),
|
||||||
|
rejectedObjects: z.number().nullish(),
|
||||||
|
warnings: z.number().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const objectInstanceRecordSchema = z
|
||||||
|
.object({
|
||||||
|
objectInstanceId: z.string(),
|
||||||
|
uri: z.string(),
|
||||||
|
uriHash: z.string().optional(),
|
||||||
|
sha256: z.string().optional(),
|
||||||
|
objectType: z.string(),
|
||||||
|
result: z.string().nullish(),
|
||||||
|
detailSummary: z.string().nullish(),
|
||||||
|
repoId: z.string(),
|
||||||
|
ppId: z.string(),
|
||||||
|
sourceSection: z.string().nullish(),
|
||||||
|
rejected: z.boolean().optional(),
|
||||||
|
rejectReason: z.string().nullish(),
|
||||||
|
sizeBytes: z.number().nullish(),
|
||||||
|
rawAvailable: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const chainEdgeRecordSchema = z
|
||||||
|
.object({
|
||||||
|
relation: z.string(),
|
||||||
|
fromUri: z.string().nullish(),
|
||||||
|
toUri: z.string().nullish(),
|
||||||
|
toObjectInstanceId: z.string().nullish(),
|
||||||
|
toSha256: z.string().nullish(),
|
||||||
|
status: z.string().nullish(),
|
||||||
|
evidence: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
/** Sparse record returned by /objects/by-uri (index hit, no lazy scan). */
|
||||||
|
export const objectUriIndexRecordSchema = z
|
||||||
|
.object({
|
||||||
|
uri: z.string(),
|
||||||
|
sha256: z.string(),
|
||||||
|
objectInstanceId: z.string(),
|
||||||
|
repoId: z.string(),
|
||||||
|
ppId: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const exportJobRecordSchema = z
|
||||||
|
.object({
|
||||||
|
jobId: z.string(),
|
||||||
|
runId: z.string(),
|
||||||
|
scope: z.string(),
|
||||||
|
repoId: z.string().nullish(),
|
||||||
|
ppId: z.string().nullish(),
|
||||||
|
status: z.string(),
|
||||||
|
createdAt: z.string().nullish(),
|
||||||
|
finishedAt: z.string().nullish(),
|
||||||
|
outputPath: z.string().nullish(),
|
||||||
|
objectCount: z.number().nullish(),
|
||||||
|
bytesWritten: z.number().nullish(),
|
||||||
|
error: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const projectionRecordSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.number().optional(),
|
||||||
|
sha256: z.string().optional(),
|
||||||
|
objectType: z.string().optional(),
|
||||||
|
parseStatus: z.string().optional(),
|
||||||
|
errorSummary: z.string().nullish(),
|
||||||
|
projection: z.record(z.unknown()).nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const validationIssueSchema = z
|
||||||
|
.object({
|
||||||
|
severity: z.string().nullish(),
|
||||||
|
reasonCode: z.string().nullish(),
|
||||||
|
reasonText: z.string().nullish(),
|
||||||
|
rfcRefs: z.array(z.string()).optional(),
|
||||||
|
evidence: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const validationStageSchema = z
|
||||||
|
.object({
|
||||||
|
status: z.string().nullish(),
|
||||||
|
stage: z.string().nullish(),
|
||||||
|
mode: z.string().nullish(),
|
||||||
|
projectionStatus: z.string().nullish(),
|
||||||
|
issues: z.array(validationIssueSchema).optional(),
|
||||||
|
detailSummary: z.string().nullish(),
|
||||||
|
edgesCount: z.number().nullish(),
|
||||||
|
note: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const validationSummarySchema = z
|
||||||
|
.object({
|
||||||
|
finalStatus: z.string().nullish(),
|
||||||
|
auditResult: z.string().nullish(),
|
||||||
|
detailSummary: z.string().nullish(),
|
||||||
|
parsevalidate: validationStageSchema.nullish(),
|
||||||
|
chainvalidate: validationStageSchema.nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const explainRecordSchema = z
|
||||||
|
.object({
|
||||||
|
finalStatus: z.string().nullish(),
|
||||||
|
auditResult: z.string().nullish(),
|
||||||
|
authoritative: z.boolean().optional(),
|
||||||
|
explainMode: z.string().nullish(),
|
||||||
|
parsevalidate: validationStageSchema.nullish(),
|
||||||
|
chainvalidate: validationStageSchema.nullish(),
|
||||||
|
chainEdges: z.array(chainEdgeRecordSchema).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const searchResultSchema = z
|
||||||
|
.object({
|
||||||
|
objects: z.array(objectInstanceRecordSchema),
|
||||||
|
repos: z.array(repositoryRecordSchema),
|
||||||
|
publicationPoints: z.array(publicationPointRecordSchema),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const healthSchema = z
|
||||||
|
.object({
|
||||||
|
status: z.string(),
|
||||||
|
service: z.string().optional(),
|
||||||
|
version: z.number().optional(),
|
||||||
|
latestReadyRun: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const serviceInfoSchema = z
|
||||||
|
.object({
|
||||||
|
service: z.string(),
|
||||||
|
version: z.number(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const manifestFileEntrySchema = z
|
||||||
|
.object({
|
||||||
|
fileName: z.string(),
|
||||||
|
hashHex: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const revokedCertEntrySchema = z
|
||||||
|
.object({
|
||||||
|
serialNumberHex: z.string().nullish(),
|
||||||
|
serialNumber: z.string().nullish(),
|
||||||
|
revocationDate: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const runSummarySchema = z
|
||||||
|
.object({
|
||||||
|
publicationPoints: z.number().optional(),
|
||||||
|
objects: z.number().optional(),
|
||||||
|
repos: z.number().optional(),
|
||||||
|
vrps: z.number().optional(),
|
||||||
|
aspas: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const statsMapSchema = z.record(z.number());
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Envelope */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const pageSchema = z.object({
|
||||||
|
nextCursor: z.string().nullable(),
|
||||||
|
limit: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaSchema = z
|
||||||
|
.object({
|
||||||
|
runId: z.string().nullish(),
|
||||||
|
schemaVersion: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export function envelopeSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
data: dataSchema,
|
||||||
|
page: pageSchema.nullish(),
|
||||||
|
meta: metaSchema.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inferred types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type RunCounts = z.infer<typeof runCountsSchema>;
|
||||||
|
export type RunRecord = z.infer<typeof runRecordSchema>;
|
||||||
|
export type RepositoryRecord = z.infer<typeof repositoryRecordSchema>;
|
||||||
|
export type PublicationPointRecord = z.infer<typeof publicationPointRecordSchema>;
|
||||||
|
export type ObjectInstanceRecord = z.infer<typeof objectInstanceRecordSchema>;
|
||||||
|
export type ChainEdgeRecord = z.infer<typeof chainEdgeRecordSchema>;
|
||||||
|
export type ExportJobRecord = z.infer<typeof exportJobRecordSchema>;
|
||||||
|
export type ProjectionRecord = z.infer<typeof projectionRecordSchema>;
|
||||||
|
export type ValidationIssue = z.infer<typeof validationIssueSchema>;
|
||||||
|
export type ValidationStage = z.infer<typeof validationStageSchema>;
|
||||||
|
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
|
||||||
|
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
|
||||||
|
export type SearchResult = z.infer<typeof searchResultSchema>;
|
||||||
|
export type HealthStatus = z.infer<typeof healthSchema>;
|
||||||
|
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
|
||||||
|
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;
|
||||||
|
export type RevokedCertEntry = z.infer<typeof revokedCertEntrySchema>;
|
||||||
|
export type RunSummary = z.infer<typeof runSummarySchema>;
|
||||||
|
|
||||||
|
export interface ListResult<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
runId: string | undefined;
|
||||||
|
}
|
||||||
450
ui/rpki-explorer/src/api/service.ts
Normal file
450
ui/rpki-explorer/src/api/service.ts
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Typed endpoint functions for the rpki_query_service API.
|
||||||
|
* Every function validates the response envelope with zod before returning.
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiFetch, apiPost, withQuery, type QueryParams } from "./client";
|
||||||
|
import {
|
||||||
|
chainEdgeRecordSchema,
|
||||||
|
envelopeSchema,
|
||||||
|
exportJobRecordSchema,
|
||||||
|
explainRecordSchema,
|
||||||
|
healthSchema,
|
||||||
|
manifestFileEntrySchema,
|
||||||
|
objectInstanceRecordSchema,
|
||||||
|
projectionRecordSchema,
|
||||||
|
publicationPointRecordSchema,
|
||||||
|
repositoryRecordSchema,
|
||||||
|
revokedCertEntrySchema,
|
||||||
|
runRecordSchema,
|
||||||
|
runSummarySchema,
|
||||||
|
searchResultSchema,
|
||||||
|
serviceInfoSchema,
|
||||||
|
statsMapSchema,
|
||||||
|
validationSummarySchema,
|
||||||
|
type ChainEdgeRecord,
|
||||||
|
type ExportJobRecord,
|
||||||
|
type ExplainRecord,
|
||||||
|
type HealthStatus,
|
||||||
|
type ListResult,
|
||||||
|
type ManifestFileEntry,
|
||||||
|
type ObjectInstanceRecord,
|
||||||
|
type ProjectionRecord,
|
||||||
|
type PublicationPointRecord,
|
||||||
|
type RepositoryRecord,
|
||||||
|
type RevokedCertEntry,
|
||||||
|
type RunRecord,
|
||||||
|
type RunSummary,
|
||||||
|
type SearchResult,
|
||||||
|
type ServiceInfo,
|
||||||
|
type ValidationSummary,
|
||||||
|
} from "./schemas";
|
||||||
|
|
||||||
|
const API = "/api/v1";
|
||||||
|
|
||||||
|
function runBase(runId: string): string {
|
||||||
|
return `${API}/runs/${encodeURIComponent(runId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getData<S extends z.ZodTypeAny>(
|
||||||
|
path: string,
|
||||||
|
schema: S,
|
||||||
|
params?: QueryParams,
|
||||||
|
): Promise<z.infer<S>> {
|
||||||
|
const raw = await apiFetch<unknown>(withQuery(path, params));
|
||||||
|
return envelopeSchema(schema).parse(raw).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getList<S extends z.ZodTypeAny>(
|
||||||
|
path: string,
|
||||||
|
schema: S,
|
||||||
|
params?: QueryParams,
|
||||||
|
): Promise<ListResult<z.infer<S>>> {
|
||||||
|
const raw = await apiFetch<unknown>(withQuery(path, params));
|
||||||
|
const parsed = envelopeSchema(z.array(schema)).parse(raw);
|
||||||
|
return {
|
||||||
|
items: parsed.data,
|
||||||
|
nextCursor: parsed.page?.nextCursor ?? null,
|
||||||
|
runId: parsed.meta?.runId ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Service / health */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function getServiceInfo(): Promise<ServiceInfo> {
|
||||||
|
return getData(`${API}`, serviceInfoSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealth(): Promise<HealthStatus> {
|
||||||
|
return getData(`${API}/healthz`, healthSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Runs */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type PageParams = {
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listRuns(params: PageParams = {}): Promise<ListResult<RunRecord>> {
|
||||||
|
return getList(`${API}/runs`, runRecordSchema, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestRun(): Promise<RunRecord> {
|
||||||
|
return getData(`${API}/latest_run`, runRecordSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(runId: string): Promise<RunRecord> {
|
||||||
|
return getData(runBase(runId), runRecordSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunArtifacts(runId: string): Promise<Record<string, string>> {
|
||||||
|
return getData(`${runBase(runId)}/artifacts`, z.record(z.string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRunSummary(runId: string): Promise<RunSummary> {
|
||||||
|
return getData(`${runBase(runId)}/summary`, runSummarySchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Repositories */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function listRepos(
|
||||||
|
runId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<RepositoryRecord>> {
|
||||||
|
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepo(runId: string, repoId: string): Promise<RepositoryRecord> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}`,
|
||||||
|
repositoryRecordSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoStats {
|
||||||
|
publicationPoints?: number;
|
||||||
|
objects?: number;
|
||||||
|
rejectedObjects?: number;
|
||||||
|
syncDurationMsTotal?: number;
|
||||||
|
phases?: Record<string, number>;
|
||||||
|
terminalStates?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepoStats(runId: string, repoId: string): Promise<RepoStats> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/stats`,
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
publicationPoints: z.number().optional(),
|
||||||
|
objects: z.number().optional(),
|
||||||
|
rejectedObjects: z.number().optional(),
|
||||||
|
syncDurationMsTotal: z.number().optional(),
|
||||||
|
phases: z.record(z.number()).optional(),
|
||||||
|
terminalStates: z.record(z.number()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Publication points */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function listPublicationPoints(
|
||||||
|
runId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<PublicationPointRecord>> {
|
||||||
|
return getList(`${runBase(runId)}/publication-points`, publicationPointRecordSchema, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRepoPublicationPoints(
|
||||||
|
runId: string,
|
||||||
|
repoId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<PublicationPointRecord>> {
|
||||||
|
return getList(
|
||||||
|
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/publication-points`,
|
||||||
|
publicationPointRecordSchema,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicationPoint(
|
||||||
|
runId: string,
|
||||||
|
ppId: string,
|
||||||
|
): Promise<PublicationPointRecord> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}`,
|
||||||
|
publicationPointRecordSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PpStats {
|
||||||
|
objects?: number;
|
||||||
|
rejectedObjects?: number;
|
||||||
|
warnings?: number;
|
||||||
|
repoSyncSource?: string;
|
||||||
|
repoSyncPhase?: string;
|
||||||
|
repoSyncDurationMs?: number;
|
||||||
|
repoTerminalState?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicationPointStats(runId: string, ppId: string): Promise<PpStats> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/stats`,
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
objects: z.number().optional(),
|
||||||
|
rejectedObjects: z.number().optional(),
|
||||||
|
warnings: z.number().optional(),
|
||||||
|
repoSyncSource: z.string().optional(),
|
||||||
|
repoSyncPhase: z.string().optional(),
|
||||||
|
repoSyncDurationMs: z.number().optional(),
|
||||||
|
repoTerminalState: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Objects */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface ObjectFilters extends PageParams {
|
||||||
|
type?: string;
|
||||||
|
result?: string;
|
||||||
|
rejected?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
q?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterParams(filters: ObjectFilters): QueryParams {
|
||||||
|
return {
|
||||||
|
limit: filters.limit,
|
||||||
|
cursor: filters.cursor,
|
||||||
|
type: filters.type,
|
||||||
|
result: filters.result,
|
||||||
|
rejected: filters.rejected === undefined ? undefined : String(filters.rejected),
|
||||||
|
reason: filters.reason,
|
||||||
|
q: filters.q,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listObjects(
|
||||||
|
runId: string,
|
||||||
|
filters: ObjectFilters = {},
|
||||||
|
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||||
|
return getList(`${runBase(runId)}/objects`, objectInstanceRecordSchema, filterParams(filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRepoObjects(
|
||||||
|
runId: string,
|
||||||
|
repoId: string,
|
||||||
|
filters: ObjectFilters = {},
|
||||||
|
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||||
|
return getList(
|
||||||
|
`${runBase(runId)}/repos/${encodeURIComponent(repoId)}/objects`,
|
||||||
|
objectInstanceRecordSchema,
|
||||||
|
filterParams(filters),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPublicationPointObjects(
|
||||||
|
runId: string,
|
||||||
|
ppId: string,
|
||||||
|
filters: ObjectFilters = {},
|
||||||
|
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||||
|
return getList(
|
||||||
|
`${runBase(runId)}/publication-points/${encodeURIComponent(ppId)}/objects`,
|
||||||
|
objectInstanceRecordSchema,
|
||||||
|
filterParams(filters),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueFilters extends PageParams {
|
||||||
|
reason?: string;
|
||||||
|
type?: string;
|
||||||
|
repoId?: string;
|
||||||
|
ppId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listIssues(
|
||||||
|
runId: string,
|
||||||
|
filters: IssueFilters = {},
|
||||||
|
): Promise<ListResult<ObjectInstanceRecord>> {
|
||||||
|
return getList(`${runBase(runId)}/issues`, objectInstanceRecordSchema, {
|
||||||
|
limit: filters.limit,
|
||||||
|
cursor: filters.cursor,
|
||||||
|
reason: filters.reason,
|
||||||
|
type: filters.type,
|
||||||
|
repoId: filters.repoId,
|
||||||
|
ppId: filters.ppId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectByUri(
|
||||||
|
runId: string,
|
||||||
|
uri: string,
|
||||||
|
): Promise<ObjectInstanceRecord> {
|
||||||
|
return getData(`${runBase(runId)}/objects/by-uri`, objectInstanceRecordSchema, { uri });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObject(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
): Promise<ObjectInstanceRecord> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}`,
|
||||||
|
objectInstanceRecordSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectProjection(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
): Promise<ProjectionRecord | null> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed`,
|
||||||
|
projectionRecordSchema.nullable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectValidation(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
): Promise<ValidationSummary> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation`,
|
||||||
|
validationSummarySchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectChain(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
): Promise<ChainEdgeRecord[]> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/chain`,
|
||||||
|
z.array(chainEdgeRecordSchema),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listManifestFiles(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<ManifestFileEntry>> {
|
||||||
|
return getList(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/manifest-files`,
|
||||||
|
manifestFileEntrySchema,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRevokedCertificates(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<RevokedCertEntry>> {
|
||||||
|
return getList(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/parsed/revoked-certs`,
|
||||||
|
revokedCertEntrySchema,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainObjectValidation(
|
||||||
|
runId: string,
|
||||||
|
objectInstanceId: string,
|
||||||
|
options: { forceRefresh?: boolean } = {},
|
||||||
|
): Promise<ExplainRecord> {
|
||||||
|
return apiPost<unknown>(
|
||||||
|
`${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/validation/explain`,
|
||||||
|
{ forceRefresh: options.forceRefresh ?? false },
|
||||||
|
).then((raw) => envelopeSchema(explainRecordSchema).parse(raw).data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rawObjectUrl(runId: string, objectInstanceId: string): string {
|
||||||
|
return `${runBase(runId)}/objects/${encodeURIComponent(objectInstanceId)}/raw`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Search */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function searchRun(
|
||||||
|
runId: string,
|
||||||
|
q: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Stats */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function getStatsObjectTypes(runId: string): Promise<Record<string, number>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsReasons(runId: string): Promise<Record<string, number>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/reasons`, statsMapSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsDownloads(runId: string): Promise<Record<string, unknown>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/downloads`, z.record(z.unknown()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Exports */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
scope: "repo" | "publication_point" | "object_set";
|
||||||
|
repoId?: string;
|
||||||
|
ppId?: string;
|
||||||
|
objectInstanceIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExport(
|
||||||
|
runId: string,
|
||||||
|
request: ExportRequest,
|
||||||
|
): Promise<ExportJobRecord> {
|
||||||
|
const body: Record<string, unknown> = { scope: request.scope };
|
||||||
|
if (request.repoId) body.repoId = request.repoId;
|
||||||
|
if (request.ppId) body.ppId = request.ppId;
|
||||||
|
if (request.objectInstanceIds) body.objectInstanceIds = request.objectInstanceIds;
|
||||||
|
return apiPost<unknown>(`${runBase(runId)}/exports`, body).then(
|
||||||
|
(raw) => envelopeSchema(exportJobRecordSchema).parse(raw).data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExports(
|
||||||
|
runId: string,
|
||||||
|
params: PageParams = {},
|
||||||
|
): Promise<ListResult<ExportJobRecord>> {
|
||||||
|
return getList(`${runBase(runId)}/exports`, exportJobRecordSchema, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExportJob(runId: string, jobId: string): Promise<ExportJobRecord> {
|
||||||
|
return getData(
|
||||||
|
`${runBase(runId)}/exports/${encodeURIComponent(jobId)}`,
|
||||||
|
exportJobRecordSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportDownloadUrl(runId: string, jobId: string): string {
|
||||||
|
return `${runBase(runId)}/exports/${encodeURIComponent(jobId)}/download`;
|
||||||
|
}
|
||||||
@ -1,103 +1,48 @@
|
|||||||
import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react";
|
import { Suspense, lazy } from "react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { Shell } from "../components/Shell";
|
||||||
import { Shell } from "../components/layout/Shell";
|
import { LoadingBlock } from "../components/StateBlock";
|
||||||
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
|
|
||||||
import { OverviewPage } from "../features/overview/OverviewPage";
|
|
||||||
import { RepositoriesPage } from "../features/repositories/RepositoriesPage";
|
|
||||||
import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState";
|
|
||||||
|
|
||||||
type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api";
|
const OverviewPage = lazy(() => import("../pages/OverviewPage"));
|
||||||
|
const RepositoriesPage = lazy(() => import("../pages/RepositoriesPage"));
|
||||||
const navigationItems = [
|
const RepositoryDetailPage = lazy(() => import("../pages/RepositoryDetailPage"));
|
||||||
{ id: "overview", label: "Overview", icon: Home },
|
const PublicationPointsPage = lazy(() => import("../pages/PublicationPointsPage"));
|
||||||
{ id: "repositories", label: "Repositories", icon: Database },
|
const PublicationPointDetailPage = lazy(
|
||||||
{ id: "publication-points", label: "Publication Points", icon: GitBranch },
|
() => import("../pages/PublicationPointDetailPage"),
|
||||||
{ id: "objects", label: "Objects", icon: Braces },
|
);
|
||||||
{ id: "validation", label: "Validation", icon: ShieldCheck },
|
const ObjectsPage = lazy(() => import("../pages/ObjectsPage"));
|
||||||
{ id: "exports", label: "Exports", icon: PackageOpen },
|
const ObjectDetailPage = lazy(() => import("../pages/ObjectDetailPage"));
|
||||||
{ id: "runs", label: "Runs", icon: Activity },
|
const ValidationPage = lazy(() => import("../pages/ValidationPage"));
|
||||||
{ id: "api", label: "API", icon: FileSearch }
|
const SearchPage = lazy(() => import("../pages/SearchPage"));
|
||||||
] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>;
|
const ExportsPage = lazy(() => import("../pages/ExportsPage"));
|
||||||
|
const RunsPage = lazy(() => import("../pages/RunsPage"));
|
||||||
const comingSoonCopy: Record<Exclude<ViewId, "overview" | "repositories" | "objects">, { title: string; description: string; alternative: string }> = {
|
const ApiStatusPage = lazy(() => import("../pages/ApiStatusPage"));
|
||||||
"publication-points": {
|
const NotFoundPage = lazy(() => import("../pages/NotFoundPage"));
|
||||||
title: "Publication Points",
|
|
||||||
description: "Dedicated publication point analytics are not implemented yet.",
|
|
||||||
alternative: "Use Repositories to drill down from repository to publication point and object rows."
|
|
||||||
},
|
|
||||||
validation: {
|
|
||||||
title: "Validation",
|
|
||||||
description: "A dedicated validation investigation workspace is planned but not available in this MVP.",
|
|
||||||
alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence."
|
|
||||||
},
|
|
||||||
exports: {
|
|
||||||
title: "Exports",
|
|
||||||
description: "A standalone export job browser is planned but not available yet.",
|
|
||||||
alternative: "Use Object Detail to trigger object or publication point export workflows."
|
|
||||||
},
|
|
||||||
runs: {
|
|
||||||
title: "Runs",
|
|
||||||
description: "Historical run navigation is not implemented in the current Explorer UI.",
|
|
||||||
alternative: "The current view always uses the latest indexed run from query service."
|
|
||||||
},
|
|
||||||
api: {
|
|
||||||
title: "API",
|
|
||||||
description: "The API reference page is planned but not implemented yet.",
|
|
||||||
alternative: "Use the project README and query service endpoints while the UI reference page is pending."
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeView, setActiveView] = useState<ViewId>("overview");
|
|
||||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
|
|
||||||
const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState);
|
|
||||||
|
|
||||||
const openObject = (objectInstanceId: string) => {
|
|
||||||
setSelectedObjectInstanceId(objectInstanceId);
|
|
||||||
setActiveView("objects");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderActiveView = () => {
|
|
||||||
if (activeView === "objects") {
|
|
||||||
return <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} />;
|
|
||||||
}
|
|
||||||
if (activeView === "repositories") {
|
|
||||||
return (
|
|
||||||
<RepositoriesPage
|
|
||||||
browserState={repositoryBrowserState}
|
|
||||||
onBrowserStateChange={setRepositoryBrowserState}
|
|
||||||
onOpenObject={openObject}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (activeView === "overview") {
|
|
||||||
return <OverviewPage />;
|
|
||||||
}
|
|
||||||
return <ComingSoonPage {...comingSoonCopy[activeView]} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
|
<Shell>
|
||||||
{renderActiveView()}
|
<Suspense fallback={<LoadingBlock label="Loading page…" />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<OverviewPage />} />
|
||||||
|
<Route path="/repositories" element={<RepositoriesPage />} />
|
||||||
|
<Route path="/repositories/:repoId" element={<RepositoryDetailPage />} />
|
||||||
|
<Route path="/publication-points" element={<PublicationPointsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/publication-points/:ppId"
|
||||||
|
element={<PublicationPointDetailPage />}
|
||||||
|
/>
|
||||||
|
<Route path="/objects" element={<ObjectsPage />} />
|
||||||
|
<Route path="/objects/:objectInstanceId" element={<ObjectDetailPage />} />
|
||||||
|
<Route path="/validation" element={<ValidationPage />} />
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
<Route path="/exports" element={<ExportsPage />} />
|
||||||
|
<Route path="/runs" element={<RunsPage />} />
|
||||||
|
<Route path="/api" element={<ApiStatusPage />} />
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) {
|
|
||||||
return (
|
|
||||||
<section className="page-stack" aria-labelledby="coming-soon-heading">
|
|
||||||
<div className="hero-dashboard compact-hero coming-soon-panel">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Planned workspace</p>
|
|
||||||
<h2 id="coming-soon-heading">{title}</h2>
|
|
||||||
<p>{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="hero-status muted-status">
|
|
||||||
<strong>Coming soon</strong>
|
|
||||||
<span>{alternative}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { queryClient } from "./queryClient";
|
import { queryClient } from "./queryClient";
|
||||||
|
|
||||||
export function AppProviders({ children }: PropsWithChildren) {
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 10_000,
|
staleTime: 30_000,
|
||||||
gcTime: 10 * 60_000,
|
gcTime: 10 * 60_000,
|
||||||
retry: 1,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnWindowFocus: false
|
retry: (failureCount, error) => {
|
||||||
|
// Never retry client errors (4xx); retry transient failures once.
|
||||||
|
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return failureCount < 1;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: false
|
retry: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal file
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import { truncateMiddle } from "../lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a long identifier (URI / hash / id) truncated with an always-available
|
||||||
|
* copy button. The full value is exposed via the `title` attribute.
|
||||||
|
*/
|
||||||
|
export function CopyableValue({
|
||||||
|
value,
|
||||||
|
max = 36,
|
||||||
|
label = "value",
|
||||||
|
}: {
|
||||||
|
value: string | null | undefined;
|
||||||
|
/** Max rendered characters before middle-truncation. */
|
||||||
|
max?: number;
|
||||||
|
/** Accessible description of what is copied, e.g. "object URI". */
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!value) return <span className="text-faint">—</span>;
|
||||||
|
|
||||||
|
const onCopy = async (event: MouseEvent) => {
|
||||||
|
// Don't trigger row-level click handlers (navigation) when copying.
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API unavailable (permissions, non-secure context) — select
|
||||||
|
// fallback is intentionally omitted; the title attribute exposes the
|
||||||
|
// full value for manual copying.
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="copyable" title={value}>
|
||||||
|
<span className="copyable-text">{truncateMiddle(value, max)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`copy-btn${copied ? " copied" : ""}`}
|
||||||
|
onClick={onCopy}
|
||||||
|
aria-label={`Copy ${label}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal file
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous/Next controls for cursor-paged lists.
|
||||||
|
*/
|
||||||
|
export function CursorPagerControls({
|
||||||
|
page,
|
||||||
|
canPrev,
|
||||||
|
hasNext,
|
||||||
|
loading,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
itemCount,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
canPrev: boolean;
|
||||||
|
hasNext: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
itemCount?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="pager">
|
||||||
|
<span className="pager-info">
|
||||||
|
Page {page}
|
||||||
|
{itemCount !== undefined ? ` · ${itemCount} rows` : ""}
|
||||||
|
{loading ? " · loading…" : ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!canPrev || loading}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={13} aria-hidden="true" /> Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!hasNext || loading}
|
||||||
|
>
|
||||||
|
Next <ChevronRight size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal file
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { EmptyBlock, ErrorBlock } from "./StateBlock";
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string;
|
||||||
|
header: ReactNode;
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
/** Right-align (numeric) cells. */
|
||||||
|
numeric?: boolean;
|
||||||
|
/** Extra class on the td/th. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
rows: T[];
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
onRetry?: () => void;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyHint?: string;
|
||||||
|
caption?: string;
|
||||||
|
/** Row click handler — renders rows as clickable. */
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Number of skeleton rows while loading. */
|
||||||
|
skeletonRows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-dense table with unified loading / error / empty states.
|
||||||
|
* Renders inside a horizontal-scroll wrapper so narrow viewports never
|
||||||
|
* overflow the document.
|
||||||
|
*/
|
||||||
|
export function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
loading = false,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
emptyTitle = "No rows",
|
||||||
|
emptyHint,
|
||||||
|
caption,
|
||||||
|
onRowClick,
|
||||||
|
skeletonRows = 6,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
if (error !== undefined && error !== null) {
|
||||||
|
return <ErrorBlock error={error} onRetry={onRetry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-label="Loading table rows">
|
||||||
|
{Array.from({ length: skeletonRows }, (_, i) => (
|
||||||
|
<div className="skeleton-row" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <EmptyBlock title={emptyTitle} hint={emptyHint} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table">
|
||||||
|
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
scope="col"
|
||||||
|
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={rowKey(row)}
|
||||||
|
className={onRowClick ? "clickable" : undefined}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
||||||
|
>
|
||||||
|
{col.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal file
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
tone,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
sub?: ReactNode;
|
||||||
|
tone?: "red" | "amber" | "blue";
|
||||||
|
to?: string;
|
||||||
|
}) {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className="kpi-label">{label}</span>
|
||||||
|
<span className="kpi-value">{value}</span>
|
||||||
|
{sub ? <span className="kpi-sub">{sub}</span> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const className = `kpi-card${tone ? ` tone-${tone}` : ""}`;
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className={className}>
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className={className}>{body}</div>;
|
||||||
|
}
|
||||||
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal file
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ListResult, ObjectInstanceRecord } from "../api/schemas";
|
||||||
|
import type { ObjectFilters } from "../api/service";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { objectTypeLabel, truncateMiddle } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { CopyableValue } from "./CopyableValue";
|
||||||
|
import { CursorPagerControls } from "./CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "./DataTable";
|
||||||
|
import { StatusPill } from "./StatusPill";
|
||||||
|
|
||||||
|
export interface ObjectFilterValues {
|
||||||
|
type: string;
|
||||||
|
result: string;
|
||||||
|
rejectedOnly: boolean;
|
||||||
|
q: string;
|
||||||
|
}
|
||||||
|
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
||||||
|
type: "",
|
||||||
|
result: "",
|
||||||
|
rejectedOnly: false,
|
||||||
|
q: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
|
||||||
|
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
|
||||||
|
const RESULT_OPTIONS = ["ok", "error", "skipped"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter bar + paginated object table. The `fetcher` determines the scope
|
||||||
|
* (global / repo / publication point / issues). Filter state is controlled
|
||||||
|
* by the caller so standalone pages can sync it to the URL.
|
||||||
|
*/
|
||||||
|
export function ObjectsTable({
|
||||||
|
runId,
|
||||||
|
queryKeyScope,
|
||||||
|
fetcher,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
showReason = true,
|
||||||
|
}: {
|
||||||
|
runId: string;
|
||||||
|
queryKeyScope: string;
|
||||||
|
fetcher: (filters: ObjectFilters) => Promise<ListResult<ObjectInstanceRecord>>;
|
||||||
|
filters: ObjectFilterValues;
|
||||||
|
onFiltersChange: (next: ObjectFilterValues) => void;
|
||||||
|
showReason?: boolean;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [qDraft, setQDraft] = useState(filters.q);
|
||||||
|
|
||||||
|
// Keep the draft input in sync when filters change externally (URL nav).
|
||||||
|
useEffect(() => {
|
||||||
|
setQDraft(filters.q);
|
||||||
|
}, [filters.q]);
|
||||||
|
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
|
||||||
|
const pager = useCursorPager(filterKey);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["objects", queryKeyScope, runId, filters, pager.cursor],
|
||||||
|
queryFn: () =>
|
||||||
|
fetcher({
|
||||||
|
limit: 50,
|
||||||
|
cursor: pager.cursor,
|
||||||
|
type: filters.type || undefined,
|
||||||
|
result: filters.result || undefined,
|
||||||
|
rejected: filters.rejectedOnly || undefined,
|
||||||
|
q: filters.q || undefined,
|
||||||
|
}),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<ObjectInstanceRecord>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Type",
|
||||||
|
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "uri",
|
||||||
|
header: "URI",
|
||||||
|
render: (obj) => <CopyableValue value={obj.uri} max={64} label="object URI" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sha256",
|
||||||
|
header: "SHA-256",
|
||||||
|
render: (obj) => (
|
||||||
|
<span className="mono text-muted" title={obj.sha256 ?? undefined}>
|
||||||
|
{obj.sha256 ? truncateMiddle(obj.sha256, 18) : "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Source",
|
||||||
|
render: (obj) => <StatusPill status={obj.sourceSection} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "result",
|
||||||
|
header: "Result",
|
||||||
|
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
||||||
|
},
|
||||||
|
...(showReason
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
header: "Reject reason",
|
||||||
|
render: (obj: ObjectInstanceRecord) =>
|
||||||
|
obj.rejectReason ? (
|
||||||
|
<span className="mono text-small" title={obj.rejectReason}>
|
||||||
|
{truncateMiddle(obj.rejectReason, 60)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-faint">—</span>
|
||||||
|
),
|
||||||
|
} satisfies Column<ObjectInstanceRecord>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
[showReason],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitQ = () => {
|
||||||
|
if (qDraft.trim() !== filters.q) {
|
||||||
|
onFiltersChange({ ...filters, q: qDraft.trim() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="panel-header filter-bar" role="search">
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor={`${queryKeyScope}-type`}>Type</label>
|
||||||
|
<select
|
||||||
|
id={`${queryKeyScope}-type`}
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">all types</option>
|
||||||
|
{TYPE_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{objectTypeLabel(t)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor={`${queryKeyScope}-result`}>Result</label>
|
||||||
|
<select
|
||||||
|
id={`${queryKeyScope}-result`}
|
||||||
|
value={filters.result}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, result: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">all results</option>
|
||||||
|
{RESULT_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
||||||
|
<input
|
||||||
|
id={`${queryKeyScope}-q`}
|
||||||
|
type="search"
|
||||||
|
className="mono"
|
||||||
|
value={qDraft}
|
||||||
|
onChange={(e) => setQDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submitQ();
|
||||||
|
}}
|
||||||
|
onBlur={submitQ}
|
||||||
|
placeholder="substring…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="filter-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.rejectedOnly}
|
||||||
|
onChange={(e) => onFiltersChange({ ...filters, rejectedOnly: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Rejected only
|
||||||
|
</label>
|
||||||
|
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
||||||
|
server-side filters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={query.data?.items ?? []}
|
||||||
|
rowKey={(obj) => obj.objectInstanceId}
|
||||||
|
loading={query.isPending}
|
||||||
|
error={query.isError ? query.error : undefined}
|
||||||
|
onRetry={() => query.refetch()}
|
||||||
|
emptyTitle="No objects match"
|
||||||
|
emptyHint="Try relaxing the filters."
|
||||||
|
caption="RPKI objects"
|
||||||
|
onRowClick={(obj) =>
|
||||||
|
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={query.data?.nextCursor != null}
|
||||||
|
loading={query.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(query.data?.nextCursor ?? null)}
|
||||||
|
itemCount={query.data?.items.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal file
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="page-title-block">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="page-actions">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
ui/rpki-explorer/src/components/Panel.tsx
Normal file
34
ui/rpki-explorer/src/components/Panel.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Panel with header (title + optional tools) and body. */
|
||||||
|
export function Panel({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
tools,
|
||||||
|
children,
|
||||||
|
flush = false,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
tools?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
/** Remove body padding (for tables that span the full width). */
|
||||||
|
flush?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={`panel ${className}`.trim()}>
|
||||||
|
{title || tools ? (
|
||||||
|
<header className="panel-header">
|
||||||
|
<div>
|
||||||
|
{title ? <h2>{title}</h2> : null}
|
||||||
|
{subtitle ? <div className="panel-subtitle">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{tools ? <div className="panel-tools">{tools}</div> : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className={`panel-body${flush ? " flush" : ""}`}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal file
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
||||||
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
|
import { CopyableValue } from "./CopyableValue";
|
||||||
|
import { CursorPagerControls } from "./CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "./DataTable";
|
||||||
|
import { EmptyBlock, LoadingBlock, Notice } from "./StateBlock";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatInt, formatUtc } from "../lib/format";
|
||||||
|
import { asArray, asNumber, asRecord, asString } from "../lib/projection";
|
||||||
|
|
||||||
|
function MetaRow({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd className={mono ? "mono" : undefined}>{value ?? <span className="text-faint">—</span>}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
|
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["manifest-files", runId, objectInstanceId, pager.cursor],
|
||||||
|
queryFn: () => listManifestFiles(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<{ fileName: string; hashHex: string }>[] = [
|
||||||
|
{ key: "file", header: "File", render: (row) => <span className="mono">{row.fileName}</span> },
|
||||||
|
{
|
||||||
|
key: "hash",
|
||||||
|
header: "Hash",
|
||||||
|
render: (row) => <CopyableValue value={row.hashHex} max={40} label="file hash" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query.isPending) return <LoadingBlock label="Loading manifest file list…" />;
|
||||||
|
if (query.isError) return <Notice kind="error">Failed to load the manifest file list.</Notice>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={query.data.items}
|
||||||
|
rowKey={(row) => row.fileName}
|
||||||
|
emptyTitle="Manifest has no file entries"
|
||||||
|
caption="Manifest file list"
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={query.data.nextCursor != null}
|
||||||
|
loading={query.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
|
||||||
|
itemCount={query.data.items.length}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
|
const pager = useCursorPager(`${runId}:${objectInstanceId}:crl`);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["revoked-certs", runId, objectInstanceId, pager.cursor],
|
||||||
|
queryFn: () =>
|
||||||
|
listRevokedCertificates(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
|
||||||
|
{
|
||||||
|
key: "serial",
|
||||||
|
header: "Serial number",
|
||||||
|
render: (row) => (
|
||||||
|
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query.isPending) return <LoadingBlock label="Loading revoked certificates…" />;
|
||||||
|
if (query.isError) return <Notice kind="error">Failed to load the revocation list.</Notice>;
|
||||||
|
const rows = query.data.items.map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
_key: `${row.serialNumberHex ?? row.serialNumber ?? "entry"}-${index}`,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(row) => row._key}
|
||||||
|
emptyTitle="No revoked certificates"
|
||||||
|
caption="Revoked certificates"
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={query.data.nextCursor != null}
|
||||||
|
loading={query.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
|
||||||
|
itemCount={query.data.items.length}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoaView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
|
const roa = asRecord(projection.roa);
|
||||||
|
const families = asArray(roa?.ipAddressFamilies);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<MetaRow label="AS ID" value={asNumber(roa?.asId) ?? asString(roa?.asId)} mono />
|
||||||
|
</dl>
|
||||||
|
{families.map((family, i) => {
|
||||||
|
const fam = asRecord(family);
|
||||||
|
const addresses = asArray(fam?.addresses);
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<h3 className="text-small" style={{ margin: "12px 0 6px" }}>
|
||||||
|
Address family {asString(fam?.afi) ?? `#${i + 1}`}
|
||||||
|
</h3>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "prefix",
|
||||||
|
header: "Prefix",
|
||||||
|
render: (row: Record<string, unknown>) => (
|
||||||
|
<span className="mono">{asString(row.prefix) ?? "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxLength",
|
||||||
|
header: "maxLength",
|
||||||
|
numeric: true,
|
||||||
|
render: (row: Record<string, unknown>) => asNumber(row.maxLength) ?? "—",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rows={addresses.map((a) => asRecord(a) ?? {})}
|
||||||
|
rowKey={(row) => `${asString(row.prefix)}-${asNumber(row.maxLength)}`}
|
||||||
|
emptyTitle="No prefixes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
|
const aspa = asRecord(projection.aspa);
|
||||||
|
const providers = asArray(aspa?.providerAsIds).map((p) => asNumber(p) ?? asString(p));
|
||||||
|
return (
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<MetaRow label="Customer AS" value={asNumber(aspa?.customerAsId) ?? asString(aspa?.customerAsId)} mono />
|
||||||
|
<MetaRow
|
||||||
|
label="Provider ASes"
|
||||||
|
value={
|
||||||
|
providers.length ? (
|
||||||
|
<span className="chip-list">
|
||||||
|
{providers.map((p, i) => (
|
||||||
|
<span className="chip" key={i}>AS{String(p)}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
|
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
|
||||||
|
const validity = asRecord(cert.validity);
|
||||||
|
const resources = asRecord(cert.resources);
|
||||||
|
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
|
||||||
|
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
|
||||||
|
return (
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
||||||
|
<MetaRow label="Issuer" value={asString(cert.issuer)} mono />
|
||||||
|
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
||||||
|
<MetaRow
|
||||||
|
label="Validity"
|
||||||
|
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
|
||||||
|
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
|
||||||
|
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
|
||||||
|
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
|
||||||
|
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
|
||||||
|
<MetaRow
|
||||||
|
label="IP resources"
|
||||||
|
value={
|
||||||
|
ipBlocks.length ? (
|
||||||
|
<span className="chip-list">
|
||||||
|
{ipBlocks.slice(0, 12).map((b, i) => (
|
||||||
|
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
|
||||||
|
))}
|
||||||
|
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AS resources"
|
||||||
|
value={
|
||||||
|
asBlocks.length ? (
|
||||||
|
<span className="chip-list">
|
||||||
|
{asBlocks.slice(0, 12).map((b, i) => (
|
||||||
|
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
|
||||||
|
))}
|
||||||
|
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured rendering of a parsed object projection, per object type.
|
||||||
|
*/
|
||||||
|
export function ProjectionView({
|
||||||
|
record,
|
||||||
|
runId,
|
||||||
|
objectInstanceId,
|
||||||
|
}: {
|
||||||
|
record: ProjectionRecord | null;
|
||||||
|
runId: string;
|
||||||
|
objectInstanceId: string;
|
||||||
|
}) {
|
||||||
|
if (!record || !record.projection) {
|
||||||
|
return (
|
||||||
|
<EmptyBlock
|
||||||
|
title="Parsed projection unavailable"
|
||||||
|
hint="The query service needs --repo-bytes-db to build parsed projections for this object."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (record.parseStatus === "error") {
|
||||||
|
return <Notice kind="error">Projection failed: {record.errorSummary ?? "unknown parse error"}</Notice>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projection = record.projection;
|
||||||
|
const type = (record.objectType ?? "").toLowerCase();
|
||||||
|
|
||||||
|
if (type === "roa") return <RoaView projection={projection} />;
|
||||||
|
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
|
||||||
|
|
||||||
|
if (type === "mft") {
|
||||||
|
const manifest = asRecord(projection.manifest);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<MetaRow label="Manifest number" value={asString(manifest?.manifestNumberHex ?? manifest?.manifestNumber)} mono />
|
||||||
|
<MetaRow label="thisUpdate" value={formatUtc(asString(manifest?.thisUpdate))} />
|
||||||
|
<MetaRow label="nextUpdate" value={formatUtc(asString(manifest?.nextUpdate))} />
|
||||||
|
<MetaRow label="File hash algorithm" value={asString(manifest?.fileHashAlg)} mono />
|
||||||
|
<MetaRow label="File count" value={formatInt(asNumber(manifest?.fileCount))} />
|
||||||
|
</dl>
|
||||||
|
<ManifestFiles runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "crl") {
|
||||||
|
const crl = asRecord(projection.crl);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
|
||||||
|
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
|
||||||
|
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
|
||||||
|
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
|
||||||
|
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
|
||||||
|
</dl>
|
||||||
|
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "cer" || type === "ee" || type === "router_cert") {
|
||||||
|
return <CertificateView projection={projection} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className="json-block" data-testid="projection-json">
|
||||||
|
{JSON.stringify(projection, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
ui/rpki-explorer/src/components/Shell.tsx
Normal file
176
ui/rpki-explorer/src/components/Shell.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Braces,
|
||||||
|
FileBox,
|
||||||
|
FolderTree,
|
||||||
|
History,
|
||||||
|
LayoutDashboard,
|
||||||
|
PackageOpen,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
|
Search as SearchIcon,
|
||||||
|
Server,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { getHealth, listRuns } from "../api/service";
|
||||||
|
import { LATEST_RUN, useRunParam, useSetRunParam } from "../lib/run";
|
||||||
|
import { StatusPill } from "./StatusPill";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ path: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
||||||
|
{ path: "/repositories", label: "Repositories", icon: Server, end: false },
|
||||||
|
{ path: "/publication-points", label: "Publication Points", icon: FolderTree, end: false },
|
||||||
|
{ path: "/objects", label: "Objects", icon: FileBox, end: false },
|
||||||
|
{ path: "/validation", label: "Validation", icon: ShieldCheck, end: false },
|
||||||
|
{ path: "/exports", label: "Exports", icon: PackageOpen, end: false },
|
||||||
|
{ path: "/runs", label: "Runs", icon: History, end: false },
|
||||||
|
{ path: "/api", label: "API", icon: Braces, end: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLLAPSE_KEY = "rpki-explorer.sidebar-collapsed";
|
||||||
|
|
||||||
|
function ConnectionPill() {
|
||||||
|
const healthQuery = useQuery({
|
||||||
|
queryKey: ["health"],
|
||||||
|
queryFn: getHealth,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (healthQuery.isPending) {
|
||||||
|
return <StatusPill status="pending" label="connecting" />;
|
||||||
|
}
|
||||||
|
if (healthQuery.isError) {
|
||||||
|
return <StatusPill status="error" label="API unreachable" />;
|
||||||
|
}
|
||||||
|
const health = healthQuery.data;
|
||||||
|
return (
|
||||||
|
<span title={health.latestReadyRun ? `Latest indexed run: ${health.latestReadyRun}` : undefined}>
|
||||||
|
<StatusPill status="ok" label="connected" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunSelector() {
|
||||||
|
const runParam = useRunParam();
|
||||||
|
const setRunParam = useSetRunParam();
|
||||||
|
const runsQuery = useQuery({
|
||||||
|
queryKey: ["runs", "selector"],
|
||||||
|
queryFn: () => listRuns({ limit: 25 }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||||
|
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topbar-run">
|
||||||
|
<label htmlFor="run-selector">Run</label>
|
||||||
|
<select
|
||||||
|
id="run-selector"
|
||||||
|
value={runParam ?? ""}
|
||||||
|
onChange={(event) => setRunParam(event.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">{latestId ? `latest (${latestId})` : LATEST_RUN}</option>
|
||||||
|
{(runsQuery.data?.items ?? []).map((run) => (
|
||||||
|
<option key={run.runId} value={run.runId}>
|
||||||
|
{run.runId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopbarSearch() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const runParam = useRunParam();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const q = value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
const params = new URLSearchParams({ q });
|
||||||
|
if (runParam) params.set("run", runParam);
|
||||||
|
navigate(`/search?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="topbar-search" role="search" onSubmit={onSubmit}>
|
||||||
|
<span className="search-icon" aria-hidden="true">
|
||||||
|
<SearchIcon size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder="Search URI, SHA-256, repository host…"
|
||||||
|
aria-label="Global search"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ children }: { children: ReactNode }) {
|
||||||
|
const runParam = useRunParam();
|
||||||
|
const [collapsed, setCollapsed] = useState(
|
||||||
|
() => localStorage.getItem(COLLAPSE_KEY) === "1",
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, prev ? "0" : "1");
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = runParam ? `?run=${encodeURIComponent(runParam)}` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
||||||
|
<div className="shell-brand">
|
||||||
|
<span className="brand-badge" aria-hidden="true">
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="brand-name">RPKI Explorer</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shell-topbar">
|
||||||
|
<TopbarSearch />
|
||||||
|
<RunSelector />
|
||||||
|
<ConnectionPill />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="shell-nav" aria-label="Primary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-toggle"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<PanelLeftOpen size={15} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose size={15} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{!collapsed && <span>Collapse</span>}
|
||||||
|
</button>
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={{ pathname: item.path, search }}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
||||||
|
>
|
||||||
|
<item.icon size={15} aria-hidden="true" />
|
||||||
|
<span className="nav-label">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<div className="nav-footer">ours RP query layer</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="shell-main">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal file
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { AlertTriangle, Inbox, RefreshCw } from "lucide-react";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
|
||||||
|
export function LoadingBlock({ label = "Loading…" }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="state-block" role="status" aria-live="polite">
|
||||||
|
<span className="spinner" aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBlock({
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
title = "Failed to load data",
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
onRetry?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const message =
|
||||||
|
error instanceof ApiError
|
||||||
|
? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}`
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error";
|
||||||
|
return (
|
||||||
|
<div className="banner error" role="alert">
|
||||||
|
<AlertTriangle size={16} aria-hidden="true" />
|
||||||
|
<div className="banner-body">
|
||||||
|
<strong>{title}.</strong> {message}
|
||||||
|
</div>
|
||||||
|
{onRetry ? (
|
||||||
|
<button type="button" className="btn small" onClick={onRetry}>
|
||||||
|
<RefreshCw size={13} aria-hidden="true" /> Retry
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyBlock({
|
||||||
|
title = "No data",
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="state-block">
|
||||||
|
<Inbox size={22} aria-hidden="true" />
|
||||||
|
<span className="state-title">{title}</span>
|
||||||
|
{hint ? <span className="text-muted text-small">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline warning/info banner. */
|
||||||
|
export function Notice({
|
||||||
|
kind,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
kind: "info" | "warning" | "error";
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`banner ${kind}`} role={kind === "error" ? "alert" : "status"}>
|
||||||
|
<AlertTriangle size={16} aria-hidden="true" />
|
||||||
|
<div className="banner-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal file
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Status pill with a deterministic status → tone mapping. The label is
|
||||||
|
* always rendered as text (status never relies on color alone).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tone = "green" | "amber" | "red" | "blue" | "slate";
|
||||||
|
|
||||||
|
const TONE_BY_STATUS: Record<string, Tone> = {
|
||||||
|
ok: "green",
|
||||||
|
valid: "green",
|
||||||
|
ready: "green",
|
||||||
|
complete: "green",
|
||||||
|
fresh: "green",
|
||||||
|
success: "green",
|
||||||
|
linked: "green",
|
||||||
|
connected: "green",
|
||||||
|
running: "blue",
|
||||||
|
building: "amber",
|
||||||
|
warnings: "amber",
|
||||||
|
warning: "amber",
|
||||||
|
cached: "amber",
|
||||||
|
partial: "amber",
|
||||||
|
stale: "amber",
|
||||||
|
error: "red",
|
||||||
|
invalid: "red",
|
||||||
|
failed: "red",
|
||||||
|
rejected: "red",
|
||||||
|
failed_no_cache: "red",
|
||||||
|
missing: "red",
|
||||||
|
skipped: "slate",
|
||||||
|
unknown: "slate",
|
||||||
|
pending: "slate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toneForStatus(status: string | null | undefined): Tone {
|
||||||
|
if (!status) return "slate";
|
||||||
|
return TONE_BY_STATUS[status.toLowerCase()] ?? "slate";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
status: string | null | undefined;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const text = label ?? status ?? "unknown";
|
||||||
|
return (
|
||||||
|
<span className={`pill tone-${toneForStatus(status)}`}>
|
||||||
|
<span className="pill-dot" aria-hidden="true" />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal file
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
id: string;
|
||||||
|
label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessible tab strip with roving tabindex and arrow-key navigation.
|
||||||
|
* Controlled: the parent owns the active id (usually synced to the URL).
|
||||||
|
*/
|
||||||
|
export function Tabs({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
tabs: TabItem[];
|
||||||
|
active: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
const refs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
||||||
|
const index = tabs.findIndex((t) => t.id === active);
|
||||||
|
if (index < 0) return;
|
||||||
|
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||||
|
const next = tabs[(index + delta + tabs.length) % tabs.length];
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(next.id);
|
||||||
|
refs.current.get(next.id)?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tabs" role="tablist" aria-label={ariaLabel} onKeyDown={onKeyDown}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const selected = tab.id === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) refs.current.set(tab.id, el);
|
||||||
|
else refs.current.delete(tab.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`tab-${tab.id}`}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={`tabpanel-${tab.id}`}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPanel({
|
||||||
|
id,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
active: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (id !== active) return null;
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" id={`tabpanel-${id}`} aria-labelledby={`tab-${id}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal file
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import type { ExportJobRecord } from "../api/schemas";
|
||||||
|
import { exportDownloadUrl } from "../api/service";
|
||||||
|
import { formatInt } from "../lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentational status line for an async export job. Polling is the
|
||||||
|
* caller's responsibility (react-query `refetchInterval`).
|
||||||
|
*/
|
||||||
|
export function WorkflowStatus({
|
||||||
|
job,
|
||||||
|
runId,
|
||||||
|
}: {
|
||||||
|
job: ExportJobRecord;
|
||||||
|
runId: string;
|
||||||
|
}) {
|
||||||
|
if (job.status === "complete") {
|
||||||
|
return (
|
||||||
|
<span className="workflow-status" role="status">
|
||||||
|
<CheckCircle2 size={15} aria-hidden="true" color="var(--green-600)" />
|
||||||
|
<span>
|
||||||
|
Export ready · {formatInt(job.objectCount)} objects
|
||||||
|
</span>
|
||||||
|
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
|
||||||
|
Download tar
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job.status === "failed") {
|
||||||
|
return (
|
||||||
|
<span className="workflow-status" role="alert">
|
||||||
|
<XCircle size={15} aria-hidden="true" color="var(--red-600)" />
|
||||||
|
<span>Export failed{job.error ? `: ${job.error}` : ""}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="workflow-status" role="status" aria-live="polite">
|
||||||
|
<span className="spinner small" aria-hidden="true" />
|
||||||
|
<span>Export running…</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { Check, Copy } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface CopyableValueProps {
|
|
||||||
className?: string;
|
|
||||||
displayValue?: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TooltipPosition {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
|
|
||||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
|
||||||
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (copyState === "idle") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [copyState]);
|
|
||||||
|
|
||||||
const showTooltip = (target: HTMLElement) => {
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
setTooltipPosition({
|
|
||||||
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
|
|
||||||
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const copy = async () => {
|
|
||||||
try {
|
|
||||||
await copyText(value);
|
|
||||||
setCopyState("copied");
|
|
||||||
} catch {
|
|
||||||
setCopyState("failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
|
|
||||||
<span
|
|
||||||
className="copyable-value-text"
|
|
||||||
onBlur={() => setTooltipPosition(null)}
|
|
||||||
onFocus={(event) => showTooltip(event.currentTarget)}
|
|
||||||
onMouseEnter={(event) => showTooltip(event.currentTarget)}
|
|
||||||
onMouseLeave={() => setTooltipPosition(null)}
|
|
||||||
tabIndex={0}
|
|
||||||
title={value}
|
|
||||||
>
|
|
||||||
{displayValue ?? value}
|
|
||||||
</span>
|
|
||||||
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
|
|
||||||
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
|
|
||||||
</button>
|
|
||||||
{tooltipPosition ? (
|
|
||||||
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyText(value: string) {
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = value;
|
|
||||||
textArea.style.position = "fixed";
|
|
||||||
textArea.style.opacity = "0";
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
interface CursorPagerProps {
|
|
||||||
label?: string;
|
|
||||||
isFetching: boolean;
|
|
||||||
nextCursor: string | null;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
pageNumber: number;
|
|
||||||
previousCount: number;
|
|
||||||
rangeLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CursorPager({
|
|
||||||
label,
|
|
||||||
isFetching,
|
|
||||||
nextCursor,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
pageNumber,
|
|
||||||
previousCount,
|
|
||||||
rangeLabel
|
|
||||||
}: CursorPagerProps) {
|
|
||||||
return (
|
|
||||||
<div className="cursor-pager" aria-label="Cursor pagination">
|
|
||||||
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
|
|
||||||
<span>
|
|
||||||
{label ?? `Page ${pageNumber}`}
|
|
||||||
{rangeLabel ? <small>{rangeLabel}</small> : null}
|
|
||||||
</span>
|
|
||||||
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
export interface PageState {
|
|
||||||
cursor: string | null;
|
|
||||||
previous: Array<string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advancePage(page: PageState, nextCursor: string | null): PageState {
|
|
||||||
if (!nextCursor) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
cursor: nextCursor,
|
|
||||||
previous: [...page.previous, page.cursor]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function previousPage(page: PageState): PageState {
|
|
||||||
if (page.previous.length === 0) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
cursor: page.previous[page.previous.length - 1] ?? null,
|
|
||||||
previous: page.previous.slice(0, -1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
|
|
||||||
import type { PropsWithChildren } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export interface NavigationItem<Id extends string = string> {
|
|
||||||
id: Id;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShellProps<Id extends string> extends PropsWithChildren {
|
|
||||||
activeView: Id;
|
|
||||||
navigationItems: Array<NavigationItem<Id>>;
|
|
||||||
onNavigate: (id: Id) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
|
|
||||||
<aside className="sidebar" aria-label="Primary navigation">
|
|
||||||
<div className="brand">
|
|
||||||
<div className="brand-mark">R</div>
|
|
||||||
<div className="brand-copy">
|
|
||||||
<h1 className="brand-title">RPKI Explorer</h1>
|
|
||||||
<div className="brand-subtitle">Validation Intelligence</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav className="nav-list">
|
|
||||||
{navigationItems.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-current={activeView === item.id ? "page" : undefined}
|
|
||||||
aria-label={isSidebarCollapsed ? item.label : undefined}
|
|
||||||
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => onNavigate(item.id)}
|
|
||||||
title={isSidebarCollapsed ? item.label : undefined}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<item.icon aria-hidden="true" size={18} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<button
|
|
||||||
aria-expanded={!isSidebarCollapsed}
|
|
||||||
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
|
||||||
className="sidebar-toggle"
|
|
||||||
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
|
|
||||||
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
|
|
||||||
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
<main className="main-panel">
|
|
||||||
<header className="topbar">
|
|
||||||
<div className="run-selector readonly-run" aria-label="Current run">
|
|
||||||
<span>Run</span>
|
|
||||||
<strong>latest indexed run</strong>
|
|
||||||
</div>
|
|
||||||
<div className="topbar-actions">
|
|
||||||
<label className="search-box">
|
|
||||||
<Search aria-hidden="true" size={18} />
|
|
||||||
<span className="sr-only">Exact object URI search is not active yet</span>
|
|
||||||
<input disabled placeholder="Exact URI lookup planned in M2" />
|
|
||||||
</label>
|
|
||||||
<div className="ready-pill"><span /> UI Ready</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,509 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { normalizeApiError } from "../../api/client";
|
|
||||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
|
||||||
import {
|
|
||||||
createObjectSetExport,
|
|
||||||
createPublicationPointExport,
|
|
||||||
downloadRawObject,
|
|
||||||
explainObjectValidation,
|
|
||||||
getLatestRun,
|
|
||||||
getObject,
|
|
||||||
getObjectByUri,
|
|
||||||
getObjectChain,
|
|
||||||
getObjectParsed,
|
|
||||||
getObjectValidation,
|
|
||||||
listManifestFiles,
|
|
||||||
listRevokedCertificates,
|
|
||||||
type ChainEdgeRecord,
|
|
||||||
type ObjectInstanceRecord,
|
|
||||||
type ObjectValidationRecord,
|
|
||||||
type ValidationExplainRecord
|
|
||||||
} from "../../api/queryService";
|
|
||||||
|
|
||||||
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
|
|
||||||
type ObjectTab = (typeof tabs)[number];
|
|
||||||
|
|
||||||
interface ObjectDetailPageProps {
|
|
||||||
initialObjectInstanceId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
|
|
||||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
|
|
||||||
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
|
|
||||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
|
||||||
const [searchUri, setSearchUri] = useState("");
|
|
||||||
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
|
|
||||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) {
|
|
||||||
setSelectedObjectInstanceId(initialObjectInstanceId);
|
|
||||||
setActiveTab("Validation");
|
|
||||||
}
|
|
||||||
}, [initialObjectInstanceId, selectedObjectInstanceId]);
|
|
||||||
|
|
||||||
const objectQuery = useQuery({
|
|
||||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
queryKey: ["object-detail-object", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const selectedObject = objectQuery.data?.data ?? null;
|
|
||||||
const validationQuery = useQuery({
|
|
||||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => getObjectValidation(runId, selectedObjectInstanceId ?? ""),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const parsedQuery = useQuery({
|
|
||||||
enabled: activeTab === "Parsed" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
queryKey: ["object-detail-parsed", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => getObjectParsed(runId, selectedObjectInstanceId ?? ""),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const chainQuery = useQuery({
|
|
||||||
enabled: activeTab === "Chain" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
queryKey: ["object-detail-chain", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => getObjectChain(runId, selectedObjectInstanceId ?? ""),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const manifestFilesQuery = useQuery({
|
|
||||||
enabled: activeTab === "Manifest files" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
retry: false,
|
|
||||||
queryKey: ["object-detail-manifest-files", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => listManifestFiles(runId, selectedObjectInstanceId ?? "", 50),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const revokedCertsQuery = useQuery({
|
|
||||||
enabled: activeTab === "Revoked certs" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
|
||||||
retry: false,
|
|
||||||
queryKey: ["object-detail-revoked-certs", runId, selectedObjectInstanceId],
|
|
||||||
queryFn: () => listRevokedCertificates(runId, selectedObjectInstanceId ?? "", 50),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
const explainMutation = useMutation({
|
|
||||||
mutationFn: () => explainObjectValidation(runId, selectedObjectInstanceId ?? "")
|
|
||||||
});
|
|
||||||
const uriSearchMutation = useMutation({
|
|
||||||
mutationFn: (uri: string) => getObjectByUri(runId, uri),
|
|
||||||
onSuccess: (result) => {
|
|
||||||
setSelectedObjectInstanceId(result.data.objectInstanceId);
|
|
||||||
setActiveTab("Validation");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const rawDownloadMutation = useMutation({
|
|
||||||
mutationFn: () => downloadRawObject(runId, selectedObjectInstanceId ?? "")
|
|
||||||
});
|
|
||||||
const objectExportMutation = useMutation({
|
|
||||||
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
|
|
||||||
});
|
|
||||||
const ppExportMutation = useMutation({
|
|
||||||
mutationFn: () => {
|
|
||||||
if (!selectedObject?.ppId) {
|
|
||||||
throw new Error("No publication point selected");
|
|
||||||
}
|
|
||||||
return createPublicationPointExport(runId, selectedObject.ppId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (copyState === "idle") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeout = window.setTimeout(() => setCopyState("idle"), 1800);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [copyState]);
|
|
||||||
|
|
||||||
const copySelectedUri = async () => {
|
|
||||||
if (!selectedObject?.uri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(selectedObject.uri);
|
|
||||||
setCopyState("copied");
|
|
||||||
} catch {
|
|
||||||
setCopyState("failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validation = validationQuery.data?.data ?? null;
|
|
||||||
const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
|
|
||||||
{apiError ? <ErrorBanner error={apiError} /> : null}
|
|
||||||
<div className="object-detail-only-layout">
|
|
||||||
<aside className="object-detail-card object-detail-card-expanded" aria-label="Live object detail">
|
|
||||||
<div className="object-detail-toolbar">
|
|
||||||
<form
|
|
||||||
className="search-box object-uri-search"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (searchUri.trim()) {
|
|
||||||
uriSearchMutation.mutate(searchUri.trim());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Search aria-hidden="true" size={17} />
|
|
||||||
<span className="sr-only">Search exact object URI</span>
|
|
||||||
<input
|
|
||||||
onChange={(event) => setSearchUri(event.target.value)}
|
|
||||||
placeholder="Exact URI lookup..."
|
|
||||||
value={searchUri}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<button className="filter-pill" disabled={!selectedObject?.uri} type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
|
|
||||||
<strong>{selectedObject ? `${labelObjectType(selectedObject.objectType)} selected` : "No object selected"}</strong>
|
|
||||||
</div>
|
|
||||||
{!selectedObjectInstanceId ? <div className="empty-state">Search an exact URI or open an object from Repository Browser.</div> : null}
|
|
||||||
{selectedObjectInstanceId && objectQuery.isFetching ? <LoadingLine label="Loading selected object..." /> : null}
|
|
||||||
<div className="object-hero">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Object detail · live query service</p>
|
|
||||||
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
|
|
||||||
{selectedObject ? (
|
|
||||||
<p><CopyableValue className="inline-copyable" label="selected object URI" value={selectedObject.uri} /></p>
|
|
||||||
) : (
|
|
||||||
<p>Select an object from the publication point list.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="object-actions">
|
|
||||||
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
|
|
||||||
<button className="ghost-button" disabled={!selectedObject?.uri} onClick={copySelectedUri} type="button">
|
|
||||||
<Copy size={16} /> {copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy URI"}
|
|
||||||
</button>
|
|
||||||
<button className="ghost-button" disabled={!selectedObjectInstanceId} onClick={() => objectExportMutation.mutate()} type="button">
|
|
||||||
<FileText size={16} /> Export object
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" disabled={!selectedObjectInstanceId} onClick={() => rawDownloadMutation.mutate()} type="button">
|
|
||||||
<Download size={16} /> Download raw
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedObject ? <ObjectMeta object={selectedObject} validation={validation} /> : <div className="empty-state">No object selected.</div>}
|
|
||||||
<WorkflowStatus
|
|
||||||
objectExport={objectExportMutation.data?.data ?? null}
|
|
||||||
objectExportError={objectExportMutation.error}
|
|
||||||
ppExport={ppExportMutation.data?.data ?? null}
|
|
||||||
ppExportError={ppExportMutation.error}
|
|
||||||
rawError={rawDownloadMutation.error}
|
|
||||||
rawPending={rawDownloadMutation.isPending}
|
|
||||||
searchError={uriSearchMutation.error}
|
|
||||||
searchPending={uriSearchMutation.isPending}
|
|
||||||
/>
|
|
||||||
<div className="object-actions inline-actions">
|
|
||||||
<button className="ghost-button" disabled={!selectedObject?.ppId} onClick={() => ppExportMutation.mutate()} type="button">
|
|
||||||
<FileText size={16} /> Export selected PP
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tabs" role="tablist" aria-label="Object detail tabs" onKeyDown={(event) => {
|
|
||||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const currentIndex = tabs.indexOf(activeTab);
|
|
||||||
const nextIndex = event.key === "ArrowRight"
|
|
||||||
? (currentIndex + 1) % tabs.length
|
|
||||||
: (currentIndex - 1 + tabs.length) % tabs.length;
|
|
||||||
setActiveTab(tabs[nextIndex]);
|
|
||||||
}}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
aria-controls={`object-tabpanel-${tabId(tab)}`}
|
|
||||||
aria-selected={activeTab === tab}
|
|
||||||
className={activeTab === tab ? "active" : ""}
|
|
||||||
id={`object-tab-${tabId(tab)}`}
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
role="tab"
|
|
||||||
tabIndex={activeTab === tab ? 0 : -1}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-labelledby={`object-tab-${tabId(activeTab)}`}
|
|
||||||
className="object-content-grid"
|
|
||||||
id={`object-tabpanel-${tabId(activeTab)}`}
|
|
||||||
role="tabpanel"
|
|
||||||
>
|
|
||||||
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
|
|
||||||
{activeTab === "Validation" ? (
|
|
||||||
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
|
|
||||||
) : null}
|
|
||||||
{activeTab === "Chain" ? <ChainPanel edges={chainQuery.data?.data ?? []} isFetching={chainQuery.isFetching} error={chainQuery.error} /> : null}
|
|
||||||
{activeTab === "Manifest files" ? <ArrayPanel title="Manifest files" rows={manifestFilesQuery.data?.data ?? []} isFetching={manifestFilesQuery.isFetching} error={manifestFilesQuery.error} /> : null}
|
|
||||||
{activeTab === "Revoked certs" ? <ArrayPanel title="Revoked certificates" rows={revokedCertsQuery.data?.data ?? []} isFetching={revokedCertsQuery.isFetching} error={revokedCertsQuery.error} /> : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
|
|
||||||
return (
|
|
||||||
<div className="object-meta-grid">
|
|
||||||
<Meta label="SHA256" value={object.sha256} copyable />
|
|
||||||
<Meta label="Source" value={object.sourceSection} />
|
|
||||||
<Meta label="Repository" value={object.repoId} copyable />
|
|
||||||
<Meta label="Publication Point" value={object.ppId} copyable />
|
|
||||||
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
|
|
||||||
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowStatus({
|
|
||||||
objectExport,
|
|
||||||
objectExportError,
|
|
||||||
ppExport,
|
|
||||||
ppExportError,
|
|
||||||
rawError,
|
|
||||||
rawPending,
|
|
||||||
searchError,
|
|
||||||
searchPending
|
|
||||||
}: {
|
|
||||||
objectExport: { jobId?: string; status?: string } | null;
|
|
||||||
objectExportError: unknown;
|
|
||||||
ppExport: { jobId?: string; status?: string } | null;
|
|
||||||
ppExportError: unknown;
|
|
||||||
rawError: unknown;
|
|
||||||
rawPending: boolean;
|
|
||||||
searchError: unknown;
|
|
||||||
searchPending: boolean;
|
|
||||||
}) {
|
|
||||||
const lines = [
|
|
||||||
searchPending ? "Searching object URI..." : null,
|
|
||||||
searchError ? `URI search failed: ${normalizeApiError(searchError).message}` : null,
|
|
||||||
rawPending ? "Downloading raw object..." : null,
|
|
||||||
rawError ? `Raw download failed: ${normalizeApiError(rawError).message}` : null,
|
|
||||||
objectExport ? `Object export job ${objectExport.jobId ?? ""} ${objectExport.status ?? ""}`.trim() : null,
|
|
||||||
objectExportError ? `Object export failed: ${normalizeApiError(objectExportError).message}` : null,
|
|
||||||
ppExport ? `PP export job ${ppExport.jobId ?? ""} ${ppExport.status ?? ""}`.trim() : null,
|
|
||||||
ppExportError ? `PP export failed: ${normalizeApiError(ppExportError).message}` : null
|
|
||||||
].filter((line): line is string => Boolean(line));
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="panel workflow-status">
|
|
||||||
<PanelHeading eyebrow="Workflow" title="Search / raw / export status" />
|
|
||||||
<ul className="workflow-status-list">
|
|
||||||
{lines.map((line) => (
|
|
||||||
<li key={line}>{line}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParsedPanel({ parsed, isFetching, error }: { parsed: unknown; isFetching: boolean; error: unknown }) {
|
|
||||||
if (isFetching) {
|
|
||||||
return <LoadingPanel title="Parsed projection" label="Loading parsed projection..." />;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return <NoticePanel title="Parsed projection" message={normalizeApiError(error).message} />;
|
|
||||||
}
|
|
||||||
if (!parsed) {
|
|
||||||
return <NoticePanel title="Parsed projection" message="Projection unavailable. The local query service was started without repo-bytes.db, so this object cannot be decoded on demand." />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="panel parsed-panel">
|
|
||||||
<PanelHeading eyebrow="Parsed projection" title="Projection JSON" icon={<FileText className="panel-icon" size={22} />} />
|
|
||||||
<JsonPreview value={parsed} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ValidationPanel({ validation, isFetching, explain, explainPending, onExplain, explainError }: {
|
|
||||||
validation: ObjectValidationRecord | null;
|
|
||||||
isFetching: boolean;
|
|
||||||
explain: ValidationExplainRecord | null;
|
|
||||||
explainPending: boolean;
|
|
||||||
onExplain: () => void;
|
|
||||||
explainError: unknown;
|
|
||||||
}) {
|
|
||||||
if (isFetching) {
|
|
||||||
return <LoadingPanel title="Validation" label="Loading validation summary..." />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="panel validation-panel">
|
|
||||||
<PanelHeading eyebrow="Validation" title="File and chain checks" icon={<ShieldCheck className="panel-icon success" size={22} />} />
|
|
||||||
<div className="check-list">
|
|
||||||
<ValidationCheck label="Final status" status={validation?.finalStatus ?? "unknown"} note={validation?.detailSummary ?? "Derived from run audit state."} />
|
|
||||||
<ValidationCheck label="File validation" status={validation?.fileValidation.status ?? "unknown"} note={issuesText(validation?.fileValidation.issues) ?? validation?.fileValidation.detailSummary ?? "No file issues recorded."} />
|
|
||||||
<ValidationCheck label="Chain validation" status={validation?.chainValidation.status ?? "unknown"} note={issuesText(validation?.chainValidation.issues) ?? validation?.chainValidation.note ?? "No chain issues recorded."} />
|
|
||||||
</div>
|
|
||||||
<div className="explain-box">
|
|
||||||
<button className="ghost-button" disabled={explainPending} onClick={onExplain} type="button">
|
|
||||||
{explainPending ? <Loader2 size={16} /> : <ShieldCheck size={16} />}
|
|
||||||
Explain validation
|
|
||||||
</button>
|
|
||||||
<span>Explain is an audit projection, not full authoritative revalidation.</span>
|
|
||||||
</div>
|
|
||||||
{explainError ? <div className="empty-state">Explain failed: {normalizeApiError(explainError).message}</div> : null}
|
|
||||||
{explain ? (
|
|
||||||
<div className="detail-list explain-result">
|
|
||||||
<Meta label="Mode" value={explain.explainMode} />
|
|
||||||
<Meta label="Authoritative" value={String(explain.authoritative)} />
|
|
||||||
<Meta label="Generated" value={explain.generatedAt} />
|
|
||||||
<Meta label="Edges" value={String(explain.chainEdges.length)} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; isFetching: boolean; error: unknown }) {
|
|
||||||
if (isFetching) {
|
|
||||||
return <LoadingPanel title="Certificate chain" label="Loading chain edges..." />;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return <NoticePanel title="Certificate chain" message={normalizeApiError(error).message} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="panel chain-panel">
|
|
||||||
<PanelHeading eyebrow="Certificate chain" title="Audit edge graph" icon={<GitBranch className="panel-icon" size={22} />} />
|
|
||||||
{edges.length === 0 ? <div className="empty-state">No chain edges recorded for this object.</div> : null}
|
|
||||||
<div className="chain-flow live-chain-flow">
|
|
||||||
{edges.map((edge) => (
|
|
||||||
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
|
|
||||||
<strong>{edge.relation}</strong>
|
|
||||||
<span>{edge.status}</span>
|
|
||||||
<code><CopyableValue label="chain edge URI" value={edge.toUri} /></code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArrayPanel({ title, rows, isFetching, error }: { title: string; rows: unknown[]; isFetching: boolean; error: unknown }) {
|
|
||||||
if (isFetching) {
|
|
||||||
return <LoadingPanel title={title} label={`Loading ${title.toLowerCase()}...`} />;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return <NoticePanel title={title} message={normalizeApiError(error).message} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="panel table-panel">
|
|
||||||
<PanelHeading eyebrow={title} title="Paged projection list" />
|
|
||||||
{rows.length === 0 ? <div className="empty-state">No rows returned for this object.</div> : null}
|
|
||||||
{rows.length > 0 ? <JsonPreview value={rows} /> : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingPanel({ title, label }: { title: string; label: string }) {
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<PanelHeading eyebrow={title} title={title} />
|
|
||||||
<LoadingLine label={label} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoticePanel({ title, message }: { title: string; message: string }) {
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<PanelHeading eyebrow={title} title={title} />
|
|
||||||
<div className="empty-state">{message}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelHeading({ eyebrow, title, icon }: { eyebrow: string; title: string; icon?: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">{eyebrow}</p>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
</div>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ValidationCheck({ label, status, note }: { label: string; status: string; note: string }) {
|
|
||||||
const ok = status === "valid" || status === "ok";
|
|
||||||
return (
|
|
||||||
<article className={`check-item ${ok ? "" : "warning-check"}`}>
|
|
||||||
<Check size={16} />
|
|
||||||
<div>
|
|
||||||
<strong>{label}: {status}</strong>
|
|
||||||
<p>{note}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function JsonPreview({ value }: { value: unknown }) {
|
|
||||||
const text = useMemo(() => JSON.stringify(value, null, 2), [value]);
|
|
||||||
return <pre className="json-preview">{text}</pre>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorBanner({ error }: { error: unknown }) {
|
|
||||||
return (
|
|
||||||
<div className="alert-banner" role="status">
|
|
||||||
<strong>Object API unavailable</strong>
|
|
||||||
<span>{normalizeApiError(error).message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ state }: { state: string }) {
|
|
||||||
const normalized = state.toLowerCase();
|
|
||||||
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error") || normalized.includes("invalid")
|
|
||||||
? "warning"
|
|
||||||
: normalized.includes("cache") || normalized.includes("fallback")
|
|
||||||
? "fallback"
|
|
||||||
: "healthy";
|
|
||||||
return <span className={`status-badge ${className}`}>{state}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingLine({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="loading-line">
|
|
||||||
<Loader2 size={16} />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Meta({ copyable, label, value }: { copyable?: boolean; label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="meta-item">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{copyable ? <CopyableValue label={label} value={value} /> : value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelObjectType(type: string) {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
certificate: "CER",
|
|
||||||
crl: "CRL",
|
|
||||||
manifest: "MFT",
|
|
||||||
roa: "ROA",
|
|
||||||
aspa: "ASPA"
|
|
||||||
};
|
|
||||||
return labels[type] ?? type.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabId(tab: ObjectTab) {
|
|
||||||
return tab.toLowerCase().replaceAll(" ", "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
function issuesText(issues: { summary?: string }[] | undefined) {
|
|
||||||
if (!issues || issues.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return issues.map((issue) => issue.summary).filter(Boolean).join("; ");
|
|
||||||
}
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { AlertTriangle, ArrowUpRight, CheckCircle2, Clock3, ShieldCheck } from "lucide-react";
|
|
||||||
import { objectTypeRows, overviewKpis, topRepositories, validationIssues, validationSlices } from "../../test/fixtures/dashboard";
|
|
||||||
import {
|
|
||||||
getLatestRun,
|
|
||||||
getStatsObjectTypes,
|
|
||||||
getStatsReasons,
|
|
||||||
getStatsValidation,
|
|
||||||
listRepos,
|
|
||||||
type CountsByKey,
|
|
||||||
type RepositoryRecord,
|
|
||||||
type RunRecord
|
|
||||||
} from "../../api/queryService";
|
|
||||||
import { normalizeApiError } from "../../api/client";
|
|
||||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
|
||||||
|
|
||||||
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
|
|
||||||
const objectTypeLabels: Record<string, string> = {
|
|
||||||
aspa: "ASPA",
|
|
||||||
certificate: "CER / RC",
|
|
||||||
crl: "CRL",
|
|
||||||
gbr: "GBR",
|
|
||||||
manifest: "MFT",
|
|
||||||
roa: "ROA",
|
|
||||||
router_certificate: "Router Cert"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OverviewPage() {
|
|
||||||
const latestRunQuery = useQuery({
|
|
||||||
queryKey: ["latest-run"],
|
|
||||||
queryFn: getLatestRun
|
|
||||||
});
|
|
||||||
const liveRun = latestRunQuery.data?.data;
|
|
||||||
const runId = liveRun?.runId ?? "latest";
|
|
||||||
const enabled = Boolean(liveRun);
|
|
||||||
const objectTypesQuery = useQuery({
|
|
||||||
queryKey: ["stats-object-types", runId],
|
|
||||||
queryFn: () => getStatsObjectTypes(runId),
|
|
||||||
enabled
|
|
||||||
});
|
|
||||||
const validationQuery = useQuery({
|
|
||||||
queryKey: ["stats-validation", runId],
|
|
||||||
queryFn: () => getStatsValidation(runId),
|
|
||||||
enabled
|
|
||||||
});
|
|
||||||
const reasonsQuery = useQuery({
|
|
||||||
queryKey: ["stats-reasons", runId],
|
|
||||||
queryFn: () => getStatsReasons(runId),
|
|
||||||
enabled
|
|
||||||
});
|
|
||||||
const reposQuery = useQuery({
|
|
||||||
queryKey: ["top-repos", runId],
|
|
||||||
queryFn: () => listRepos(runId, 8),
|
|
||||||
enabled
|
|
||||||
});
|
|
||||||
|
|
||||||
const error = latestRunQuery.error ? normalizeApiError(latestRunQuery.error) : null;
|
|
||||||
const kpis = liveRun ? kpisFromRun(liveRun) : overviewKpis;
|
|
||||||
const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows;
|
|
||||||
const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices;
|
|
||||||
const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100);
|
|
||||||
const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures();
|
|
||||||
const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues;
|
|
||||||
const statusText = liveRun ? statusForRun(liveRun) : "Static fixture";
|
|
||||||
const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="page-stack" aria-labelledby="overview-heading">
|
|
||||||
{error ? (
|
|
||||||
<div className="alert-banner" role="status">
|
|
||||||
<strong>Query service unavailable</strong>
|
|
||||||
<span>{error.message}. Showing static fallback data for layout inspection.</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="hero-dashboard">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">{liveRun ? `Latest ready run · ${liveRun.syncMode ?? "unknown"} mode` : "Latest ready run · fallback"}</p>
|
|
||||||
<h2 id="overview-heading">Global RPKI validation health</h2>
|
|
||||||
<p>
|
|
||||||
{liveRun
|
|
||||||
? `Live query-service view for ${liveRun.runId}, validated at ${formatTimestamp(liveRun.validationTime)}.`
|
|
||||||
: "Static fallback showing how operators inspect validation output, repository health, and input quality."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hero-status">
|
|
||||||
<ShieldCheck size={20} />
|
|
||||||
<div>
|
|
||||||
<strong>{statusText}</strong>
|
|
||||||
<span>{statusSubtext}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="metric-grid" aria-label="Overview KPI cards">
|
|
||||||
{kpis.map((metric) => (
|
|
||||||
<article className={`metric-card tone-${metric.tone}`} key={metric.label}>
|
|
||||||
<span>{metric.label}</span>
|
|
||||||
<strong>{metric.value}</strong>
|
|
||||||
<small>{metric.delta}</small>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
|
||||||
<section className="panel validation-card">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Validation</p>
|
|
||||||
<h3>Result distribution</h3>
|
|
||||||
</div>
|
|
||||||
<CheckCircle2 className="panel-icon success" size={22} />
|
|
||||||
</div>
|
|
||||||
<div className="donut-wrap">
|
|
||||||
<div className="donut" aria-label="Validation distribution donut" style={{ background: donutGradient(validationRows) }} />
|
|
||||||
<div className="donut-center">
|
|
||||||
<strong>{validPercent}%</strong>
|
|
||||||
<span>usable</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="legend-list">
|
|
||||||
{validationRows.map((slice) => (
|
|
||||||
<div className="legend-row" key={slice.label}>
|
|
||||||
<span style={{ backgroundColor: slice.color }} />
|
|
||||||
<label>{slice.label}</label>
|
|
||||||
<strong>{slice.value}%</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Objects</p>
|
|
||||||
<h3>Object type mix</h3>
|
|
||||||
</div>
|
|
||||||
<Clock3 className="panel-icon" size={22} />
|
|
||||||
</div>
|
|
||||||
<div className="bar-list">
|
|
||||||
{objectRows.map((row) => (
|
|
||||||
<div className="bar-row" key={row.type}>
|
|
||||||
<div className="bar-meta">
|
|
||||||
<strong>{row.type}</strong>
|
|
||||||
<span>{row.count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bar-track">
|
|
||||||
<span style={{ width: `${row.percent}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel table-panel wide-panel">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Repositories</p>
|
|
||||||
<h3>Top repositories by workload</h3>
|
|
||||||
</div>
|
|
||||||
<button className="ghost-button" type="button">
|
|
||||||
View all <ArrowUpRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>Transport</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Objects</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{repoRows.map((repo) => (
|
|
||||||
<tr key={repo.id}>
|
|
||||||
<td>{repo.host}</td>
|
|
||||||
<td>{repo.transport}</td>
|
|
||||||
<td>{repo.duration}</td>
|
|
||||||
<td>{repo.objects}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`status-badge ${repo.status}`}>{repo.status}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel issue-panel">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Triage</p>
|
|
||||||
<h3>Recent validation issues</h3>
|
|
||||||
</div>
|
|
||||||
<AlertTriangle className="panel-icon warning" size={22} />
|
|
||||||
</div>
|
|
||||||
<div className="issue-list">
|
|
||||||
{issueRows.map((issue) => (
|
|
||||||
<article className="issue-card" key={issue.uri}>
|
|
||||||
<div>
|
|
||||||
<span className={`severity ${issue.severity}`}>{issue.severity}</span>
|
|
||||||
<strong>{issue.type}</strong>
|
|
||||||
</div>
|
|
||||||
<p>{issue.reason}</p>
|
|
||||||
<code><CopyableValue label="issue URI" value={issue.uri} /></code>
|
|
||||||
<small>{issue.repo}</small>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function kpisFromRun(run: RunRecord) {
|
|
||||||
return [
|
|
||||||
{ label: "VRPs", value: formatNumber(run.counts.vrps), delta: "latest run", tone: "blue" },
|
|
||||||
{ label: "ASPAs", value: formatNumber(run.counts.aspas), delta: "latest run", tone: "cyan" },
|
|
||||||
{ label: "Objects", value: formatNumber(run.counts.objects), delta: "indexed inputs", tone: "blue" },
|
|
||||||
{ label: "Publication Points", value: formatNumber(run.counts.publicationPoints), delta: "indexed", tone: "purple" },
|
|
||||||
{ label: "Rejected Objects", value: formatNumber(run.counts.rejectedObjects), delta: "validation rejects", tone: "red" },
|
|
||||||
{ label: "Warnings", value: formatNumber(run.counts.warnings), delta: `${formatDuration(run.wallMs)} wall`, tone: "amber" }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectRowsFromStats(stats: CountsByKey) {
|
|
||||||
const total = sumValues(stats) || 1;
|
|
||||||
return Object.entries(stats)
|
|
||||||
.sort(([, left], [, right]) => right - left)
|
|
||||||
.slice(0, 7)
|
|
||||||
.map(([type, count]) => ({
|
|
||||||
type: objectTypeLabels[type] ?? type,
|
|
||||||
count: formatNumber(count),
|
|
||||||
percent: Math.max(1, Math.round((count / total) * 100))
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validationRowsFromStats(stats: CountsByKey) {
|
|
||||||
const total = sumValues(stats) || 1;
|
|
||||||
return Object.entries(stats)
|
|
||||||
.sort(([, left], [, right]) => right - left)
|
|
||||||
.map(([label, count], index) => ({
|
|
||||||
label: titleCase(label),
|
|
||||||
value: Math.round((count / total) * 100),
|
|
||||||
color: validationColors[index] ?? "#94a3b8"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reposFromApi(repos: RepositoryRecord[]) {
|
|
||||||
return [...repos]
|
|
||||||
.sort((left, right) => right.objects - left.objects)
|
|
||||||
.slice(0, 6)
|
|
||||||
.map((repo) => ({
|
|
||||||
host: repo.host,
|
|
||||||
id: repo.repoId,
|
|
||||||
transport: repo.transport.toUpperCase(),
|
|
||||||
duration: formatDuration(repo.syncDurationMsTotal),
|
|
||||||
objects: formatNumber(repo.objects),
|
|
||||||
status: repoStatus(repo)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reposFromFixtures() {
|
|
||||||
return topRepositories.map((repo, index) => ({
|
|
||||||
...repo,
|
|
||||||
id: fallbackRepoId(repo, index)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) {
|
|
||||||
return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function issuesFromReasons(reasons: CountsByKey) {
|
|
||||||
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
severity: "low",
|
|
||||||
type: "OK",
|
|
||||||
reason: "No validation reject reasons reported in the latest run.",
|
|
||||||
uri: "query-service://validation/reasons",
|
|
||||||
repo: "latest run"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return entries.slice(0, 4).map(([reason, count], index) => ({
|
|
||||||
severity: index === 0 ? "high" : index === 1 ? "medium" : "low",
|
|
||||||
type: "Reject",
|
|
||||||
reason: `${formatNumber(count)} object(s): ${reason}`,
|
|
||||||
uri: `query-service://validation/reasons/${index + 1}`,
|
|
||||||
repo: "latest run"
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function repoStatus(repo: RepositoryRecord) {
|
|
||||||
if (repo.terminalStates.failed_no_cache || repo.phases.rrdp_failed_rsync_failed) {
|
|
||||||
return "warning";
|
|
||||||
}
|
|
||||||
if (repo.terminalStates.fallback_current_instance || repo.phases.rsync_ok) {
|
|
||||||
return "fallback";
|
|
||||||
}
|
|
||||||
return "healthy";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusForRun(run: RunRecord) {
|
|
||||||
if (run.counts.rejectedObjects > 0 || run.counts.warnings > 0) {
|
|
||||||
return "Healthy with warnings";
|
|
||||||
}
|
|
||||||
return "Healthy";
|
|
||||||
}
|
|
||||||
|
|
||||||
function donutGradient(rows: Array<{ value: number; color: string }>) {
|
|
||||||
let start = 0;
|
|
||||||
const parts = rows.map((row) => {
|
|
||||||
const end = Math.min(100, start + row.value);
|
|
||||||
const segment = `${row.color} ${start}% ${end}%`;
|
|
||||||
start = end;
|
|
||||||
return segment;
|
|
||||||
});
|
|
||||||
return `conic-gradient(${parts.join(", ")})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumValues(values?: CountsByKey) {
|
|
||||||
return Object.values(values ?? {}).reduce((sum, value) => sum + value, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function percent(value: number, total: number) {
|
|
||||||
if (total <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.round((value / total) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number) {
|
|
||||||
return new Intl.NumberFormat("en-US").format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms: number | null) {
|
|
||||||
if (ms === null || ms === undefined) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${ms}ms`;
|
|
||||||
}
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value: string | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "unknown time";
|
|
||||||
}
|
|
||||||
return value.replace("T", " ").replace("Z", " UTC");
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(value: string) {
|
|
||||||
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
||||||
}
|
|
||||||
@ -1,467 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
|
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { CopyableValue } from "../../components/common/CopyableValue";
|
|
||||||
import { CursorPager } from "../../components/common/CursorPager";
|
|
||||||
import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging";
|
|
||||||
import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState";
|
|
||||||
import {
|
|
||||||
getLatestRun,
|
|
||||||
listObjectsForPublicationPoint,
|
|
||||||
listPublicationPointsForRepo,
|
|
||||||
listRepos,
|
|
||||||
type ObjectInstanceRecord,
|
|
||||||
type PublicationPointRecord,
|
|
||||||
type RepositoryRecord
|
|
||||||
} from "../../api/queryService";
|
|
||||||
import { normalizeApiError } from "../../api/client";
|
|
||||||
|
|
||||||
const REPO_PAGE_SIZE = 50;
|
|
||||||
const PP_PAGE_SIZE = 50;
|
|
||||||
const OBJECT_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
interface RepositoriesPageProps {
|
|
||||||
browserState: RepositoryBrowserState;
|
|
||||||
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
|
|
||||||
onOpenObject?: (objectInstanceId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
|
|
||||||
const {
|
|
||||||
selectedRepoId,
|
|
||||||
selectedPpId,
|
|
||||||
repoFilter,
|
|
||||||
ppFilter,
|
|
||||||
objectFilter,
|
|
||||||
repoPage,
|
|
||||||
ppPage,
|
|
||||||
objectPage,
|
|
||||||
isRepoPanelCollapsed,
|
|
||||||
isPpPanelCollapsed
|
|
||||||
} = browserState;
|
|
||||||
const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value }));
|
|
||||||
const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value }));
|
|
||||||
const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value }));
|
|
||||||
const setRepoPage = (updater: SetStateAction<PageState>) => {
|
|
||||||
onBrowserStateChange((state) => ({
|
|
||||||
...state,
|
|
||||||
repoPage: typeof updater === "function" ? updater(state.repoPage) : updater
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setPpPage = (updater: SetStateAction<PageState>) => {
|
|
||||||
onBrowserStateChange((state) => ({
|
|
||||||
...state,
|
|
||||||
ppPage: typeof updater === "function" ? updater(state.ppPage) : updater
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const setObjectPage = (updater: SetStateAction<PageState>) => {
|
|
||||||
onBrowserStateChange((state) => ({
|
|
||||||
...state,
|
|
||||||
objectPage: typeof updater === "function" ? updater(state.objectPage) : updater
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const latestRunQuery = useQuery({
|
|
||||||
queryKey: ["repositories-latest-run"],
|
|
||||||
queryFn: getLatestRun
|
|
||||||
});
|
|
||||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
|
||||||
const reposQuery = useQuery({
|
|
||||||
queryKey: ["repositories", runId, repoPage.cursor],
|
|
||||||
queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
|
|
||||||
enabled: Boolean(latestRunQuery.data?.data.runId)
|
|
||||||
});
|
|
||||||
|
|
||||||
const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]);
|
|
||||||
const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]);
|
|
||||||
const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
|
|
||||||
const ppsQuery = useQuery({
|
|
||||||
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
|
|
||||||
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
|
|
||||||
enabled: Boolean(selectedRepo?.repoId)
|
|
||||||
});
|
|
||||||
|
|
||||||
const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
|
|
||||||
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
|
|
||||||
const selectedPp = selectedPpId
|
|
||||||
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
|
|
||||||
: null;
|
|
||||||
const objectsQuery = useQuery({
|
|
||||||
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
|
|
||||||
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
|
|
||||||
enabled: Boolean(selectedPp?.ppId)
|
|
||||||
});
|
|
||||||
const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
|
|
||||||
const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]);
|
|
||||||
const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length;
|
|
||||||
const ppTotal = selectedRepo?.publicationPoints;
|
|
||||||
const objectTotal = selectedPp?.objects;
|
|
||||||
|
|
||||||
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="page-stack repositories-page" aria-labelledby="repositories-heading">
|
|
||||||
<div className="hero-dashboard compact-hero">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Repository browser · live query service</p>
|
|
||||||
<h2 id="repositories-heading">Repository / publication point / object browser</h2>
|
|
||||||
<p>Browse repo trees first, then expand publication points and object rows only on demand.</p>
|
|
||||||
</div>
|
|
||||||
<div className="hero-status">
|
|
||||||
<Database size={20} />
|
|
||||||
<div>
|
|
||||||
<strong>{latestRunQuery.data?.data.runId ?? "Loading run"}</strong>
|
|
||||||
<span>{repos.length ? `${repos.length} repositories loaded` : "waiting for query service"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="alert-banner" role="status">
|
|
||||||
<strong>Repository API unavailable</strong>
|
|
||||||
<span>{normalizeApiError(error).message}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
|
|
||||||
<section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
|
|
||||||
<PanelTitle
|
|
||||||
icon={<Database size={18} />}
|
|
||||||
isCollapsed={isRepoPanelCollapsed}
|
|
||||||
label="Repositories"
|
|
||||||
meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
|
||||||
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))}
|
|
||||||
toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"}
|
|
||||||
/>
|
|
||||||
{isRepoPanelCollapsed ? (
|
|
||||||
<CollapsedPanelSummary icon={<Database size={20} />} label="Repositories" value={repoTotal ?? repos.length} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CurrentPageFilter
|
|
||||||
label="Filter repositories on current page"
|
|
||||||
onChange={setRepoFilter}
|
|
||||||
placeholder="Filter host or URI..."
|
|
||||||
value={repoFilter}
|
|
||||||
/>
|
|
||||||
<div className="stack-list">
|
|
||||||
{filteredRepos.map((repo) => (
|
|
||||||
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
|
|
||||||
<button
|
|
||||||
className="stack-row-select"
|
|
||||||
onClick={() => {
|
|
||||||
onBrowserStateChange((state) => ({
|
|
||||||
...state,
|
|
||||||
selectedRepoId: repo.repoId,
|
|
||||||
selectedPpId: null,
|
|
||||||
ppFilter: "",
|
|
||||||
objectFilter: "",
|
|
||||||
ppPage: createEmptyPageState(),
|
|
||||||
objectPage: createEmptyPageState()
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>{repo.host}</strong>
|
|
||||||
<small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
|
|
||||||
</span>
|
|
||||||
<em>{repo.objects.toLocaleString()}</em>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CursorPager
|
|
||||||
isFetching={reposQuery.isFetching}
|
|
||||||
pageNumber={repoPage.previous.length + 1}
|
|
||||||
nextCursor={reposQuery.data?.page?.nextCursor ?? null}
|
|
||||||
onNext={() => setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))}
|
|
||||||
onPrevious={() => setRepoPage(previousPage)}
|
|
||||||
previousCount={repoPage.previous.length}
|
|
||||||
rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
|
|
||||||
<PanelTitle
|
|
||||||
icon={<GitBranch size={18} />}
|
|
||||||
isCollapsed={isPpPanelCollapsed}
|
|
||||||
label="Publication Points"
|
|
||||||
meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"}
|
|
||||||
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))}
|
|
||||||
toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"}
|
|
||||||
/>
|
|
||||||
{isPpPanelCollapsed ? (
|
|
||||||
<CollapsedPanelSummary icon={<GitBranch size={20} />} label="Publication Points" value={ppTotal ?? publicationPoints.length} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!selectedRepo ? (
|
|
||||||
<div className="empty-state">Select a repository to load its publication points.</div>
|
|
||||||
) : null}
|
|
||||||
<CurrentPageFilter
|
|
||||||
disabled={!selectedRepo}
|
|
||||||
label="Filter publication points on current page"
|
|
||||||
onChange={setPpFilter}
|
|
||||||
placeholder="Filter manifest, rsync, source..."
|
|
||||||
value={ppFilter}
|
|
||||||
/>
|
|
||||||
{ppsQuery.isFetching ? <LoadingLine /> : null}
|
|
||||||
<div className="stack-list">
|
|
||||||
{filteredPublicationPoints.map((pp) => (
|
|
||||||
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
|
|
||||||
<button
|
|
||||||
className="stack-row-select"
|
|
||||||
onClick={() => {
|
|
||||||
onBrowserStateChange((state) => ({
|
|
||||||
...state,
|
|
||||||
selectedPpId: pp.ppId,
|
|
||||||
objectFilter: "",
|
|
||||||
objectPage: createEmptyPageState()
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
|
|
||||||
<small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
|
|
||||||
</span>
|
|
||||||
<em>{pp.objects.toLocaleString()}</em>
|
|
||||||
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
|
|
||||||
</button>
|
|
||||||
<CopyableValue className="stack-row-copy" label="publication point URI" value={pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? pp.ppId} />
|
|
||||||
{pp.manifestRsyncUri ? <CopyableValue className="stack-row-copy secondary" label="manifest URI" value={pp.manifestRsyncUri} /> : null}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CursorPager
|
|
||||||
isFetching={ppsQuery.isFetching}
|
|
||||||
nextCursor={ppsQuery.data?.page?.nextCursor ?? null}
|
|
||||||
onNext={() => setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))}
|
|
||||||
onPrevious={() => setPpPage(previousPage)}
|
|
||||||
pageNumber={ppPage.previous.length + 1}
|
|
||||||
previousCount={ppPage.previous.length}
|
|
||||||
rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
|
|
||||||
<PanelTitle
|
|
||||||
icon={<FileCode2 size={18} />}
|
|
||||||
label="Objects"
|
|
||||||
meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"}
|
|
||||||
/>
|
|
||||||
{!selectedPp ? (
|
|
||||||
<div className="empty-state">Select a publication point to load its objects.</div>
|
|
||||||
) : null}
|
|
||||||
<CurrentPageFilter
|
|
||||||
disabled={!selectedPp}
|
|
||||||
label="Filter objects on current page"
|
|
||||||
onChange={setObjectFilter}
|
|
||||||
placeholder="Filter type, URI, hash, status..."
|
|
||||||
value={objectFilter}
|
|
||||||
/>
|
|
||||||
{objectsQuery.isFetching ? <LoadingLine /> : null}
|
|
||||||
{selectedPp && !objectsQuery.isFetching ? (
|
|
||||||
<ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
|
|
||||||
) : null}
|
|
||||||
<CursorPager
|
|
||||||
isFetching={objectsQuery.isFetching}
|
|
||||||
nextCursor={objectsQuery.data?.page?.nextCursor ?? null}
|
|
||||||
onNext={() => setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))}
|
|
||||||
onPrevious={() => setObjectPage(previousPage)}
|
|
||||||
pageNumber={objectPage.previous.length + 1}
|
|
||||||
previousCount={objectPage.previous.length}
|
|
||||||
rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelTitle({
|
|
||||||
icon,
|
|
||||||
isCollapsed,
|
|
||||||
label,
|
|
||||||
meta,
|
|
||||||
onToggleCollapse,
|
|
||||||
toggleLabel
|
|
||||||
}: {
|
|
||||||
icon: ReactNode;
|
|
||||||
isCollapsed?: boolean;
|
|
||||||
label: string;
|
|
||||||
meta: string;
|
|
||||||
onToggleCollapse?: () => void;
|
|
||||||
toggleLabel?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="panel-heading compact-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">{label}</p>
|
|
||||||
<h3>{label}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="panel-heading-actions">
|
|
||||||
<span className="panel-meta">
|
|
||||||
{icon}
|
|
||||||
{meta}
|
|
||||||
</span>
|
|
||||||
{onToggleCollapse ? (
|
|
||||||
<button aria-expanded={!isCollapsed} aria-label={toggleLabel} className="panel-collapse-button" onClick={onToggleCollapse} title={toggleLabel} type="button">
|
|
||||||
{isCollapsed ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelLeftClose aria-hidden="true" size={16} />}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
|
||||||
return (
|
|
||||||
<div className="collapsed-panel-summary">
|
|
||||||
{icon}
|
|
||||||
<strong>{label}</strong>
|
|
||||||
<span>{value.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CurrentPageFilter({
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
value
|
|
||||||
}: {
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="current-page-filter">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) {
|
|
||||||
if (objects.length === 0) {
|
|
||||||
return <div className="empty-state">No objects returned for this publication point.</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>URI</th>
|
|
||||||
<th>Hash</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{objects.map((object) => (
|
|
||||||
<tr key={object.objectInstanceId}>
|
|
||||||
<td><span className="object-type">{object.objectType}</span></td>
|
|
||||||
<td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
|
|
||||||
<td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
|
|
||||||
<td>{object.sourceSection}</td>
|
|
||||||
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
|
||||||
<td>
|
|
||||||
<button className="table-link-button" onClick={() => onOpenObject?.(object.objectInstanceId)} type="button">
|
|
||||||
Open
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ state }: { state: string }) {
|
|
||||||
const normalized = state.toLowerCase();
|
|
||||||
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error")
|
|
||||||
? "warning"
|
|
||||||
: normalized.includes("cache") || normalized.includes("fallback")
|
|
||||||
? "fallback"
|
|
||||||
: "healthy";
|
|
||||||
return <span className={`status-badge ${className}`}>{state}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingLine() {
|
|
||||||
return (
|
|
||||||
<div className="loading-line">
|
|
||||||
<Loader2 size={16} />
|
|
||||||
Loading selected branch...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortName(uri: string) {
|
|
||||||
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortHash(value: string, prefixLength: number) {
|
|
||||||
return `${value.slice(0, prefixLength)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) {
|
|
||||||
const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1;
|
|
||||||
const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1;
|
|
||||||
const totalText = total === undefined ? "unknown" : total.toLocaleString();
|
|
||||||
const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : "";
|
|
||||||
return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesNeedle(values: Array<string | number | null | undefined>, needle: string) {
|
|
||||||
const normalized = needle.trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterRepositories(repos: RepositoryRecord[], filter: string) {
|
|
||||||
return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter));
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) {
|
|
||||||
return publicationPoints.filter((pp) => matchesNeedle([
|
|
||||||
pp.ppId,
|
|
||||||
pp.manifestRsyncUri,
|
|
||||||
pp.publicationPointRsyncUri,
|
|
||||||
pp.rsyncBaseUri,
|
|
||||||
pp.rrdpNotificationUri,
|
|
||||||
pp.source,
|
|
||||||
pp.repoSyncSource,
|
|
||||||
pp.repoTerminalState,
|
|
||||||
pp.objects,
|
|
||||||
pp.rejectedObjects
|
|
||||||
], filter));
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterObjects(objects: ObjectInstanceRecord[], filter: string) {
|
|
||||||
return objects.filter((object) => matchesNeedle([
|
|
||||||
object.objectType,
|
|
||||||
object.uri,
|
|
||||||
object.sha256,
|
|
||||||
object.sourceSection,
|
|
||||||
object.result,
|
|
||||||
object.rejectReason,
|
|
||||||
object.rejected ? "rejected" : "accepted"
|
|
||||||
], filter));
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import type { PageState } from "../../components/common/cursorPaging";
|
|
||||||
|
|
||||||
export interface RepositoryBrowserState {
|
|
||||||
selectedRepoId: string | null;
|
|
||||||
selectedPpId: string | null;
|
|
||||||
repoFilter: string;
|
|
||||||
ppFilter: string;
|
|
||||||
objectFilter: string;
|
|
||||||
repoPage: PageState;
|
|
||||||
ppPage: PageState;
|
|
||||||
objectPage: PageState;
|
|
||||||
isRepoPanelCollapsed: boolean;
|
|
||||||
isPpPanelCollapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRepositoryBrowserState(): RepositoryBrowserState {
|
|
||||||
return {
|
|
||||||
selectedRepoId: null,
|
|
||||||
selectedPpId: null,
|
|
||||||
repoFilter: "",
|
|
||||||
ppFilter: "",
|
|
||||||
objectFilter: "",
|
|
||||||
repoPage: createEmptyPageState(),
|
|
||||||
ppPage: createEmptyPageState(),
|
|
||||||
objectPage: createEmptyPageState(),
|
|
||||||
isRepoPanelCollapsed: false,
|
|
||||||
isPpPanelCollapsed: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmptyPageState(): PageState {
|
|
||||||
return { cursor: null, previous: [] };
|
|
||||||
}
|
|
||||||
42
ui/rpki-explorer/src/lib/cursor.test.ts
Normal file
42
ui/rpki-explorer/src/lib/cursor.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
INITIAL_PAGER_STATE,
|
||||||
|
advancePager,
|
||||||
|
pageNumber,
|
||||||
|
retreatPager,
|
||||||
|
type PagerState,
|
||||||
|
} from "./cursor";
|
||||||
|
|
||||||
|
describe("cursor pager state machine", () => {
|
||||||
|
it("starts on page 1 with a null cursor", () => {
|
||||||
|
expect(INITIAL_PAGER_STATE.cursor).toBeNull();
|
||||||
|
expect(pageNumber(INITIAL_PAGER_STATE)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances and keeps a cursor stack for going back", () => {
|
||||||
|
let state: PagerState = INITIAL_PAGER_STATE;
|
||||||
|
state = advancePager(state, "c2");
|
||||||
|
expect(state.cursor).toBe("c2");
|
||||||
|
expect(pageNumber(state)).toBe(2);
|
||||||
|
state = advancePager(state, "c3");
|
||||||
|
expect(state.cursor).toBe("c3");
|
||||||
|
expect(pageNumber(state)).toBe(3);
|
||||||
|
|
||||||
|
state = retreatPager(state);
|
||||||
|
expect(state.cursor).toBe("c2");
|
||||||
|
expect(pageNumber(state)).toBe(2);
|
||||||
|
state = retreatPager(state);
|
||||||
|
expect(state.cursor).toBeNull();
|
||||||
|
expect(pageNumber(state)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores advance with a null cursor (no next page)", () => {
|
||||||
|
const state = advancePager(INITIAL_PAGER_STATE, null);
|
||||||
|
expect(state).toEqual(INITIAL_PAGER_STATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores retreat on the first page", () => {
|
||||||
|
const state = retreatPager(INITIAL_PAGER_STATE);
|
||||||
|
expect(state).toEqual(INITIAL_PAGER_STATE);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
ui/rpki-explorer/src/lib/cursor.ts
Normal file
72
ui/rpki-explorer/src/lib/cursor.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface CursorPager {
|
||||||
|
/** Cursor to pass to the backend for the current page (null = first page). */
|
||||||
|
cursor: string | null;
|
||||||
|
/** 1-based page number. */
|
||||||
|
page: number;
|
||||||
|
canPrev: boolean;
|
||||||
|
goNext: (nextCursor: string | null) => void;
|
||||||
|
goPrev: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagerState {
|
||||||
|
cursor: string | null;
|
||||||
|
/** Stack of cursors that produced the previous pages (page 1 = null). */
|
||||||
|
stack: (string | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_PAGER_STATE: PagerState = { cursor: null, stack: [] };
|
||||||
|
|
||||||
|
/** Advance to the page identified by `nextCursor`; unchanged when null. */
|
||||||
|
export function advancePager(state: PagerState, nextCursor: string | null): PagerState {
|
||||||
|
if (!nextCursor) return state;
|
||||||
|
return { cursor: nextCursor, stack: [...state.stack, state.cursor] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Go back one page; unchanged on the first page. */
|
||||||
|
export function retreatPager(state: PagerState): PagerState {
|
||||||
|
if (state.stack.length === 0) return state;
|
||||||
|
const stack = [...state.stack];
|
||||||
|
const cursor = stack.pop() ?? null;
|
||||||
|
return { cursor, stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageNumber(state: PagerState): number {
|
||||||
|
return state.stack.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor pager that resets whenever `resetKey` changes (run id, filters…).
|
||||||
|
*/
|
||||||
|
export function useCursorPager(resetKey: string): CursorPager {
|
||||||
|
const [state, setState] = useState<PagerState>(INITIAL_PAGER_STATE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(INITIAL_PAGER_STATE);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const goNext = useCallback((nextCursor: string | null) => {
|
||||||
|
setState((s) => advancePager(s, nextCursor));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
setState((s) => retreatPager(s));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
cursor: state.cursor,
|
||||||
|
page: pageNumber(state),
|
||||||
|
canPrev: state.stack.length > 0,
|
||||||
|
goNext,
|
||||||
|
goPrev,
|
||||||
|
reset,
|
||||||
|
}),
|
||||||
|
[state, goNext, goPrev, reset],
|
||||||
|
);
|
||||||
|
}
|
||||||
99
ui/rpki-explorer/src/lib/format.test.ts
Normal file
99
ui/rpki-explorer/src/lib/format.test.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatDurationMs,
|
||||||
|
formatInt,
|
||||||
|
formatPercent,
|
||||||
|
formatUtc,
|
||||||
|
objectTypeLabel,
|
||||||
|
parseTime,
|
||||||
|
truncateMiddle,
|
||||||
|
} from "./format";
|
||||||
|
|
||||||
|
describe("formatInt", () => {
|
||||||
|
it("formats with thousands separators", () => {
|
||||||
|
expect(formatInt(1234567)).toBe("1,234,567");
|
||||||
|
});
|
||||||
|
it("renders dash for nullish", () => {
|
||||||
|
expect(formatInt(null)).toBe("—");
|
||||||
|
expect(formatInt(undefined)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatBytes", () => {
|
||||||
|
it("keeps small values in bytes", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
});
|
||||||
|
it("scales to KiB/MiB/GiB", () => {
|
||||||
|
expect(formatBytes(2048)).toBe("2.0 KiB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
|
||||||
|
expect(formatBytes(3 * 1024 ** 3)).toBe("3.0 GiB");
|
||||||
|
});
|
||||||
|
it("renders dash for nullish", () => {
|
||||||
|
expect(formatBytes(null)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDurationMs", () => {
|
||||||
|
it("sub-second values", () => {
|
||||||
|
expect(formatDurationMs(640)).toBe("640 ms");
|
||||||
|
});
|
||||||
|
it("seconds", () => {
|
||||||
|
expect(formatDurationMs(4200)).toBe("4.2s");
|
||||||
|
expect(formatDurationMs(42_000)).toBe("42s");
|
||||||
|
});
|
||||||
|
it("minutes and hours", () => {
|
||||||
|
expect(formatDurationMs(3 * 60_000 + 4_000)).toBe("3m 04s");
|
||||||
|
expect(formatDurationMs(2 * 3_600_000 + 5 * 60_000)).toBe("2h 05m");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseTime / formatUtc", () => {
|
||||||
|
it("parses RFC3339 and renders UTC", () => {
|
||||||
|
expect(formatUtc("2026-07-17T01:46:55Z")).toBe("2026-07-17 01:46:55 UTC");
|
||||||
|
});
|
||||||
|
it("passes through unparseable input", () => {
|
||||||
|
expect(formatUtc("not-a-date")).toBe("not-a-date");
|
||||||
|
expect(parseTime("not-a-date")).toBeNull();
|
||||||
|
});
|
||||||
|
it("renders dash for missing values", () => {
|
||||||
|
expect(formatUtc(null)).toBe("—");
|
||||||
|
expect(formatUtc(undefined)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPercent", () => {
|
||||||
|
it("computes one decimal", () => {
|
||||||
|
expect(formatPercent(1, 3)).toBe("33.3%");
|
||||||
|
});
|
||||||
|
it("guards zero total", () => {
|
||||||
|
expect(formatPercent(1, 0)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateMiddle", () => {
|
||||||
|
it("keeps short values intact", () => {
|
||||||
|
expect(truncateMiddle("abc", 10)).toBe("abc");
|
||||||
|
});
|
||||||
|
it("truncates with an ellipsis keeping head and tail", () => {
|
||||||
|
const out = truncateMiddle("a".repeat(100), 21);
|
||||||
|
expect(out.length).toBe(21);
|
||||||
|
expect(out).toContain("…");
|
||||||
|
expect(out.startsWith("a".repeat(10))).toBe(true);
|
||||||
|
expect(out.endsWith("a".repeat(10))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("objectTypeLabel", () => {
|
||||||
|
it("maps known types", () => {
|
||||||
|
expect(objectTypeLabel("roa")).toBe("ROA");
|
||||||
|
expect(objectTypeLabel("mft")).toBe("Manifest");
|
||||||
|
expect(objectTypeLabel("asa")).toBe("ASPA");
|
||||||
|
});
|
||||||
|
it("uppercases unknown types", () => {
|
||||||
|
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
||||||
|
});
|
||||||
|
it("renders dash for missing", () => {
|
||||||
|
expect(objectTypeLabel(null)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
109
ui/rpki-explorer/src/lib/format.ts
Normal file
109
ui/rpki-explorer/src/lib/format.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/** Formatting helpers for numbers, bytes, durations and timestamps. */
|
||||||
|
|
||||||
|
export function formatInt(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||||
|
return value.toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let size = value;
|
||||||
|
let unit = "B";
|
||||||
|
for (const next of units) {
|
||||||
|
if (size < 1024) break;
|
||||||
|
size /= 1024;
|
||||||
|
unit = next;
|
||||||
|
}
|
||||||
|
return `${size >= 100 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDurationMs(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||||
|
if (value < 1000) return `${Math.round(value)} ms`;
|
||||||
|
const totalSeconds = Math.floor(value / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
||||||
|
const fraction = value / 1000;
|
||||||
|
return `${fraction >= 10 ? fraction.toFixed(0) : fraction.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an ISO/RFC3339 timestamp; returns null when unparseable. */
|
||||||
|
export function parseTime(value: string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-07-17 01:46:55 UTC" — stable, timezone-explicit rendering. */
|
||||||
|
export function formatUtc(value: string | null | undefined): string {
|
||||||
|
const date = parseTime(value);
|
||||||
|
if (!date) return value ? String(value) : "—";
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return (
|
||||||
|
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
|
||||||
|
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(value: string | null | undefined): string {
|
||||||
|
const date = parseTime(value);
|
||||||
|
if (!date) return "—";
|
||||||
|
const deltaMs = Date.now() - date.getTime();
|
||||||
|
const future = deltaMs < 0;
|
||||||
|
const abs = Math.abs(deltaMs);
|
||||||
|
const minutes = Math.floor(abs / 60_000);
|
||||||
|
const hours = Math.floor(abs / 3_600_000);
|
||||||
|
const days = Math.floor(abs / 86_400_000);
|
||||||
|
let text: string;
|
||||||
|
if (abs < 60_000) text = "just now";
|
||||||
|
else if (minutes < 60) text = `${minutes}m`;
|
||||||
|
else if (hours < 24) text = `${hours}h`;
|
||||||
|
else text = `${days}d`;
|
||||||
|
if (text === "just now") return text;
|
||||||
|
return future ? `in ${text}` : `${text} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(part: number, total: number): string {
|
||||||
|
if (!total) return "—";
|
||||||
|
return `${((part / total) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate a long identifier for display, keeping head and tail. */
|
||||||
|
export function truncateMiddle(value: string, max = 32): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
const head = Math.ceil((max - 1) / 2);
|
||||||
|
const tail = Math.floor((max - 1) / 2);
|
||||||
|
return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for an object type code (accepts both report-stream and projection vocabularies). */
|
||||||
|
export function objectTypeLabel(type: string | null | undefined): string {
|
||||||
|
switch ((type ?? "").toLowerCase()) {
|
||||||
|
case "roa":
|
||||||
|
return "ROA";
|
||||||
|
case "mft":
|
||||||
|
case "manifest":
|
||||||
|
return "Manifest";
|
||||||
|
case "crl":
|
||||||
|
return "CRL";
|
||||||
|
case "cer":
|
||||||
|
case "certificate":
|
||||||
|
return "Certificate";
|
||||||
|
case "asa":
|
||||||
|
case "aspa":
|
||||||
|
return "ASPA";
|
||||||
|
case "gbr":
|
||||||
|
return "Ghostbusters";
|
||||||
|
case "ee":
|
||||||
|
return "EE Certificate";
|
||||||
|
case "other":
|
||||||
|
return "Other";
|
||||||
|
default:
|
||||||
|
return type ? type.toUpperCase() : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ui/rpki-explorer/src/lib/projection.test.ts
Normal file
25
ui/rpki-explorer/src/lib/projection.test.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { asArray, asNumber, asRecord, asString } from "./projection";
|
||||||
|
|
||||||
|
describe("projection guards", () => {
|
||||||
|
it("asRecord accepts plain objects only", () => {
|
||||||
|
expect(asRecord({ a: 1 })).toEqual({ a: 1 });
|
||||||
|
expect(asRecord([1])).toBeNull();
|
||||||
|
expect(asRecord("x")).toBeNull();
|
||||||
|
expect(asRecord(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asString / asNumber narrow primitives", () => {
|
||||||
|
expect(asString("s")).toBe("s");
|
||||||
|
expect(asString(1)).toBeNull();
|
||||||
|
expect(asNumber(4)).toBe(4);
|
||||||
|
expect(asNumber("4")).toBeNull();
|
||||||
|
expect(asNumber(NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asArray defaults to empty", () => {
|
||||||
|
expect(asArray([1, 2])).toEqual([1, 2]);
|
||||||
|
expect(asArray(undefined)).toEqual([]);
|
||||||
|
expect(asArray({})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
ui/rpki-explorer/src/lib/projection.ts
Normal file
18
ui/rpki-explorer/src/lib/projection.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/** Defensive accessors for loosely-typed projection payloads. */
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asNumber(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
48
ui/rpki-explorer/src/lib/run.ts
Normal file
48
ui/rpki-explorer/src/lib/run.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Run-context helpers. The active run is carried in the `?run=` search param;
|
||||||
|
* "latest" (the default, no param) resolves server-side to the newest
|
||||||
|
* indexed run.
|
||||||
|
*/
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const LATEST_RUN = "latest";
|
||||||
|
|
||||||
|
/** The run id to use in API paths ("latest" when no explicit selection). */
|
||||||
|
export function useRunId(): string {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
return searchParams.get("run") ?? LATEST_RUN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw `?run=` value (null when following latest). */
|
||||||
|
export function useRunParam(): string | null {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
return searchParams.get("run");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a function that sets (or clears, for "latest") the `?run=` param
|
||||||
|
* while preserving the other current search params.
|
||||||
|
*/
|
||||||
|
export function useSetRunParam(): (runId: string | null) => void {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
return useCallback(
|
||||||
|
(runId: string | null) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (runId === null || runId === LATEST_RUN) {
|
||||||
|
next.delete("run");
|
||||||
|
} else {
|
||||||
|
next.set("run", runId);
|
||||||
|
}
|
||||||
|
setSearchParams(next, { preventScrollReset: true });
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the current run param to a path that already carries a query string. */
|
||||||
|
export function withRunParam(path: string, runId: string): string {
|
||||||
|
if (runId === LATEST_RUN) return path;
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
return `${path}${sep}run=${encodeURIComponent(runId)}`;
|
||||||
|
}
|
||||||
15
ui/rpki-explorer/src/lib/useRun.ts
Normal file
15
ui/rpki-explorer/src/lib/useRun.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/** Resolve the active run record for the current `?run=` context. */
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { getRun } from "../api/service";
|
||||||
|
import type { RunRecord } from "../api/schemas";
|
||||||
|
import { useRunId } from "./run";
|
||||||
|
|
||||||
|
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
||||||
|
const runId = useRunId();
|
||||||
|
const runQuery = useQuery({
|
||||||
|
queryKey: ["run", runId],
|
||||||
|
queryFn: () => getRun(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
return { runId, runQuery };
|
||||||
|
}
|
||||||
@ -1,13 +1,17 @@
|
|||||||
import React from "react";
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { AppProviders } from "./app/providers";
|
import { AppProviders } from "./app/providers";
|
||||||
import { App } from "./app/App";
|
import { App } from "./app/App";
|
||||||
import "./styles/globals.css";
|
import "./styles/tokens.css";
|
||||||
|
import "./styles/base.css";
|
||||||
|
import "./styles/shell.css";
|
||||||
|
import "./styles/components.css";
|
||||||
|
import "./styles/pages.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<StrictMode>
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<App />
|
<App />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</React.StrictMode>
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
111
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal file
111
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getHealth, getServiceInfo } from "../api/service";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { ErrorBlock } from "../components/StateBlock";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [
|
||||||
|
{ method: "GET", path: "/api/v1/healthz", note: "Service health + latest indexed run" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs", note: "Run history (cursor paged)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}", note: "Run record ('latest' accepted)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/repos", note: "Repositories (cursor paged)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/repos/{id}/publication-points", note: "PPs of a repository" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/publication-points/{id}/objects", note: "Objects of a PP (filters: type,result,rejected,reason,q)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects", note: "All objects (streamed; same filters)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects/by-uri?uri=", note: "Exact URI lookup" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/parsed", note: "Parsed projection (needs repo-bytes-db)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/validation", note: "Validation summary" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/chain", note: "Chain edges" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" },
|
||||||
|
{ method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" },
|
||||||
|
{ method: "GET", path: "/api/v1/runs/{run}/exports/{job}/download", note: "Download export tarball" },
|
||||||
|
{ method: "POST", path: "/api/v1/runs/{run}/objects/{id}/validation/explain", note: "On-demand validation explain (audit projection)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ApiStatusPage() {
|
||||||
|
const { runId, runQuery } = useRun();
|
||||||
|
const infoQuery = useQuery({ queryKey: ["service-info"], queryFn: getServiceInfo });
|
||||||
|
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="API" subtitle="Query service connection status and endpoint reference" />
|
||||||
|
|
||||||
|
<div className="detail-grid">
|
||||||
|
<Panel title="Service">
|
||||||
|
{infoQuery.isError ? (
|
||||||
|
<ErrorBlock error={infoQuery.error} onRetry={() => infoQuery.refetch()} title="Query service unreachable" />
|
||||||
|
) : (
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Service</dt>
|
||||||
|
<dd className="mono">{infoQuery.data?.service ?? "…"}</dd>
|
||||||
|
<dt>API version</dt>
|
||||||
|
<dd className="mono">{infoQuery.data?.version ?? "…"}</dd>
|
||||||
|
<dt>Health</dt>
|
||||||
|
<dd>
|
||||||
|
{healthQuery.isError ? (
|
||||||
|
<StatusPill status="error" label="unhealthy" />
|
||||||
|
) : (
|
||||||
|
<StatusPill status={healthQuery.data?.status ?? "unknown"} />
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt>Latest indexed run</dt>
|
||||||
|
<dd className="mono">{healthQuery.data?.latestReadyRun ?? "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Explorer context">
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Active run</dt>
|
||||||
|
<dd className="mono">{runQuery.data?.runId ?? runId}</dd>
|
||||||
|
<dt>Index status</dt>
|
||||||
|
<dd><StatusPill status={runQuery.data?.indexStatus} /></dd>
|
||||||
|
<dt>API base</dt>
|
||||||
|
<dd><CopyableValue value={`${window.location.origin}/api/v1`} max={64} label="API base URL" /></dd>
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel title="Deployment notes">
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<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>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>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Endpoint reference" flush>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="data-table endpoint-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Endpoint</th>
|
||||||
|
<th scope="col">Purpose</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ENDPOINTS.map((endpoint) => (
|
||||||
|
<tr key={endpoint.path}>
|
||||||
|
<td>
|
||||||
|
<span className={`method${endpoint.method === "POST" ? " post" : ""}`}>{endpoint.method}</span>
|
||||||
|
{endpoint.path}
|
||||||
|
</td>
|
||||||
|
<td>{endpoint.note}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal file
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { exportDownloadUrl, listExports } from "../api/service";
|
||||||
|
import type { ExportJobRecord } from "../api/schemas";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { Notice } from "../components/StateBlock";
|
||||||
|
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export job history for the active run. Jobs are held in the query service
|
||||||
|
* process memory, so the list resets when the service restarts.
|
||||||
|
*/
|
||||||
|
export default function ExportsPage() {
|
||||||
|
const { runId } = useRun();
|
||||||
|
|
||||||
|
const exportsQuery = useQuery({
|
||||||
|
queryKey: ["exports", runId],
|
||||||
|
queryFn: () => listExports(runId, { limit: 50 }),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const jobs = query.state.data?.items ?? [];
|
||||||
|
return jobs.some((job) => job.status === "running") ? 2000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<ExportJobRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "job",
|
||||||
|
header: "Job",
|
||||||
|
render: (job) => <span className="mono text-small">{job.jobId.slice(0, 12)}…</span>,
|
||||||
|
},
|
||||||
|
{ key: "scope", header: "Scope", render: (job) => <span className="chip">{job.scope}</span> },
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
header: "Target",
|
||||||
|
render: (job) => (
|
||||||
|
<span className="mono text-small">{job.repoId ?? job.ppId ?? "object set"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "status", header: "Status", render: (job) => <StatusPill status={job.status} /> },
|
||||||
|
{
|
||||||
|
key: "objects",
|
||||||
|
header: "Objects",
|
||||||
|
numeric: true,
|
||||||
|
render: (job) => formatInt(job.objectCount),
|
||||||
|
},
|
||||||
|
{ key: "bytes", header: "Size", numeric: true, render: (job) => formatBytes(job.bytesWritten) },
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
header: "Created",
|
||||||
|
render: (job) => <span title={formatUtc(job.createdAt)}>{formatRelative(job.createdAt)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "error",
|
||||||
|
header: "Error",
|
||||||
|
render: (job) =>
|
||||||
|
job.error ? <span className="text-small" title={job.error}>{job.error}</span> : <span className="text-faint">—</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "download",
|
||||||
|
header: "",
|
||||||
|
render: (job) =>
|
||||||
|
job.status === "complete" ? (
|
||||||
|
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Exports"
|
||||||
|
subtitle="Evidence bundles (raw object tarballs) produced by the query service"
|
||||||
|
/>
|
||||||
|
<Notice kind="info">
|
||||||
|
Export jobs live in the query service process memory and are cleared on
|
||||||
|
restart. Creating exports requires the service to run with --repo-bytes-db.
|
||||||
|
New exports can be started from an object, publication point or repository
|
||||||
|
page.
|
||||||
|
</Notice>
|
||||||
|
<Panel title="Jobs" flush>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={exportsQuery.data?.items ?? []}
|
||||||
|
rowKey={(job) => job.jobId}
|
||||||
|
loading={exportsQuery.isPending}
|
||||||
|
error={exportsQuery.isError ? exportsQuery.error : undefined}
|
||||||
|
onRetry={() => exportsQuery.refetch()}
|
||||||
|
emptyTitle="No export jobs yet"
|
||||||
|
emptyHint="Start an export from an object detail page (object set) to see it here."
|
||||||
|
caption="Export jobs"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal file
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="not-found">
|
||||||
|
<div className="not-found-code">404</div>
|
||||||
|
<p className="text-muted">This page does not exist.</p>
|
||||||
|
<p style={{ marginTop: 12 }}>
|
||||||
|
<Link className="btn" to="/">
|
||||||
|
Back to Overview
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal file
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
||||||
|
import { apiFetchBlob, saveBlob } from "../api/client";
|
||||||
|
import {
|
||||||
|
createExport,
|
||||||
|
explainObjectValidation,
|
||||||
|
getExportJob,
|
||||||
|
getObject,
|
||||||
|
getObjectChain,
|
||||||
|
getObjectProjection,
|
||||||
|
getObjectValidation,
|
||||||
|
rawObjectUrl,
|
||||||
|
} from "../api/service";
|
||||||
|
import type { ValidationIssue } from "../api/schemas";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { ProjectionView } from "../components/ProjectionView";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||||
|
import { TabPanel, Tabs } from "../components/Tabs";
|
||||||
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||||
|
import { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
||||||
|
const last = uri?.split("/").filter(Boolean).pop();
|
||||||
|
if (last && last.length <= 128) return last;
|
||||||
|
const ext = type === "mft" ? ".mft" : type === "crl" ? ".crl" : type === "roa" ? ".roa" : type === "asa" ? ".asa" : ".cer";
|
||||||
|
return `${(sha256 ?? "object").slice(0, 16)}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueList({ issues }: { issues: ValidationIssue[] }) {
|
||||||
|
if (issues.length === 0) return <p className="text-muted">No issues recorded.</p>;
|
||||||
|
return (
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<StatusPill status={issue.severity} />
|
||||||
|
{issue.reasonCode ? <code style={{ marginLeft: 8 }}>{issue.reasonCode}</code> : null}
|
||||||
|
<span style={{ marginLeft: 8 }}>{issue.reasonText ?? "—"}</span>
|
||||||
|
{issue.rfcRefs?.length ? (
|
||||||
|
<span className="text-faint text-small" style={{ marginLeft: 8 }}>
|
||||||
|
[{issue.rfcRefs.join(", ")}]
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValidationTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
|
const [forceRefresh, setForceRefresh] = useState(false);
|
||||||
|
const validationQuery = useQuery({
|
||||||
|
queryKey: ["object-validation", runId, objectInstanceId],
|
||||||
|
queryFn: () => getObjectValidation(runId, objectInstanceId),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
const explainMutation = useMutation({
|
||||||
|
mutationFn: () => explainObjectValidation(runId, objectInstanceId, { forceRefresh }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationQuery.isPending) return <LoadingBlock label="Loading validation summary…" />;
|
||||||
|
if (validationQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBlock
|
||||||
|
error={validationQuery.error}
|
||||||
|
onRetry={() => validationQuery.refetch()}
|
||||||
|
title="Failed to load validation summary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validationQuery.data;
|
||||||
|
const explain = explainMutation.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Final status</dt>
|
||||||
|
<dd><StatusPill status={validation.finalStatus} /></dd>
|
||||||
|
<dt>Audit result</dt>
|
||||||
|
<dd><StatusPill status={validation.auditResult} /></dd>
|
||||||
|
<dt>Detail</dt>
|
||||||
|
<dd>{validation.detailSummary ?? "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-small" style={{ marginBottom: 8 }}>File validation (parsevalidate)</h3>
|
||||||
|
<p style={{ marginBottom: 8 }}>
|
||||||
|
<StatusPill status={validation.parsevalidate?.status} />
|
||||||
|
</p>
|
||||||
|
<IssueList issues={validation.parsevalidate?.issues ?? []} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-small" style={{ marginBottom: 8 }}>Chain validation (chainvalidate)</h3>
|
||||||
|
<p style={{ marginBottom: 8 }}>
|
||||||
|
<StatusPill status={validation.chainvalidate?.status} />
|
||||||
|
</p>
|
||||||
|
<IssueList issues={validation.chainvalidate?.issues ?? []} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-small" style={{ marginBottom: 8 }}>Explain validation</h3>
|
||||||
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginBottom: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => explainMutation.mutate()}
|
||||||
|
disabled={explainMutation.isPending}
|
||||||
|
>
|
||||||
|
{explainMutation.isPending ? (
|
||||||
|
<span className="spinner small" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Play size={13} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{explain ? "Run again" : "Run explain"}
|
||||||
|
</button>
|
||||||
|
<label className="filter-check" style={{ paddingBottom: 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceRefresh}
|
||||||
|
onChange={(e) => setForceRefresh(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Force refresh (bypass cached explain)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{explainMutation.isError ? (
|
||||||
|
<Notice kind="error">Explain failed: {explainMutation.error.message}</Notice>
|
||||||
|
) : null}
|
||||||
|
{explain ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<Notice kind="info">
|
||||||
|
Explain mode: <strong>{explain.explainMode ?? "audit projection"}</strong> —{" "}
|
||||||
|
{explain.authoritative
|
||||||
|
? "authoritative revalidation."
|
||||||
|
: "not a full revalidation; derived from the run audit trail."}
|
||||||
|
</Notice>
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Final status</dt>
|
||||||
|
<dd><StatusPill status={explain.finalStatus} /></dd>
|
||||||
|
<dt>Parse stage</dt>
|
||||||
|
<dd><StatusPill status={explain.parsevalidate?.status} /></dd>
|
||||||
|
<dt>Chain stage</dt>
|
||||||
|
<dd>
|
||||||
|
<StatusPill status={explain.chainvalidate?.status} />{" "}
|
||||||
|
<span className="text-muted text-small">
|
||||||
|
{formatInt(explain.chainvalidate?.edgesCount)} chain edges
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
{explain.chainvalidate?.note ? (
|
||||||
|
<>
|
||||||
|
<dt>Note</dt>
|
||||||
|
<dd>{explain.chainvalidate.note}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
|
const chainQuery = useQuery({
|
||||||
|
queryKey: ["object-chain", runId, objectInstanceId],
|
||||||
|
queryFn: () => getObjectChain(runId, objectInstanceId),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chainQuery.isPending) return <LoadingBlock label="Loading chain edges…" />;
|
||||||
|
if (chainQuery.isError) {
|
||||||
|
return <ErrorBlock error={chainQuery.error} onRetry={() => chainQuery.refetch()} title="Failed to load chain" />;
|
||||||
|
}
|
||||||
|
const edges = chainQuery.data;
|
||||||
|
if (edges.length === 0) {
|
||||||
|
return <EmptyBlock title="No chain edges" hint="No issuer/manifest relationships were recorded for this object." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<(typeof edges)[number]>[] = [
|
||||||
|
{ key: "relation", header: "Relation", render: (edge) => <span className="chip">{edge.relation}</span> },
|
||||||
|
{
|
||||||
|
key: "from",
|
||||||
|
header: "From URI",
|
||||||
|
render: (edge) => <CopyableValue value={edge.fromUri} max={48} label="from URI" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "to",
|
||||||
|
header: "To URI",
|
||||||
|
render: (edge) => <CopyableValue value={edge.toUri} max={48} label="to URI" />,
|
||||||
|
},
|
||||||
|
{ key: "status", header: "Status", render: (edge) => <StatusPill status={edge.status} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return <DataTable columns={columns} rows={edges} rowKey={(e) => `${e.relation}:${e.fromUri}:${e.toUri}`} caption="Certificate chain edges" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ObjectDetailPage() {
|
||||||
|
const { objectInstanceId = "" } = useParams();
|
||||||
|
const { runId } = useRun();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get("tab") ?? "parsed";
|
||||||
|
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const objectQuery = useQuery({
|
||||||
|
queryKey: ["object", runId, objectInstanceId],
|
||||||
|
queryFn: () => getObject(runId, objectInstanceId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectionQuery = useQuery({
|
||||||
|
queryKey: ["object-parsed", runId, objectInstanceId],
|
||||||
|
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
||||||
|
enabled: tab === "parsed",
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const blob = await apiFetchBlob(rawObjectUrl(runId, objectInstanceId));
|
||||||
|
const object = objectQuery.data;
|
||||||
|
saveBlob(blob, filenameFor(object?.uri, object?.sha256, object?.objectType ?? ""));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
|
||||||
|
onSuccess: (job) => setExportJobId(job.jobId),
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Object" />
|
||||||
|
<ErrorBlock error={objectQuery.error} onRetry={() => objectQuery.refetch()} title="Failed to load object" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (objectQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Object" />
|
||||||
|
<LoadingBlock />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = objectQuery.data;
|
||||||
|
|
||||||
|
const setTab = (next: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("tab", next);
|
||||||
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||||
|
<Link to={withRunParam("/objects", runId)}>Objects</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span>{objectTypeLabel(object.objectType)}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={
|
||||||
|
<span style={{ display: "inline-flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
{objectTypeLabel(object.objectType)} object
|
||||||
|
<StatusPill status={object.rejected ? "rejected" : object.result} />
|
||||||
|
<StatusPill status={object.sourceSection} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={<CopyableValue value={object.uri} max={110} label="object URI" />}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => rawMutation.mutate()}
|
||||||
|
disabled={rawMutation.isPending}
|
||||||
|
>
|
||||||
|
{rawMutation.isPending ? <span className="spinner small" aria-hidden="true" /> : <Download size={13} aria-hidden="true" />}
|
||||||
|
Download raw
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => exportMutation.mutate()}
|
||||||
|
disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"}
|
||||||
|
>
|
||||||
|
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
||||||
|
Export object set
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rawMutation.isError ? (
|
||||||
|
<Notice kind="error">
|
||||||
|
Raw download failed: {rawMutation.error.message}. The query service needs
|
||||||
|
--repo-bytes-db to serve raw bytes.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
{exportMutation.isError ? (
|
||||||
|
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
||||||
|
) : null}
|
||||||
|
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
||||||
|
|
||||||
|
<Panel className="object-header-card">
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Object type</dt>
|
||||||
|
<dd><span className="chip">{objectTypeLabel(object.objectType)}</span></dd>
|
||||||
|
<dt>URI</dt>
|
||||||
|
<dd><CopyableValue value={object.uri} max={110} label="object URI" /></dd>
|
||||||
|
<dt>SHA-256</dt>
|
||||||
|
<dd><CopyableValue value={object.sha256} max={72} label="object sha256" /></dd>
|
||||||
|
<dt>Instance ID</dt>
|
||||||
|
<dd><CopyableValue value={object.objectInstanceId} max={56} label="object instance id" /></dd>
|
||||||
|
<dt>Result</dt>
|
||||||
|
<dd><StatusPill status={object.rejected ? "rejected" : object.result} /></dd>
|
||||||
|
<dt>Reject reason</dt>
|
||||||
|
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
||||||
|
<dt>Detail</dt>
|
||||||
|
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
||||||
|
<dt>Size</dt>
|
||||||
|
<dd>{formatBytes(object.sizeBytes)}</dd>
|
||||||
|
<dt>Repository</dt>
|
||||||
|
<dd>
|
||||||
|
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
||||||
|
{object.repoId}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
<dt>Publication point</dt>
|
||||||
|
<dd>
|
||||||
|
<Link className="mono" to={withRunParam(`/publication-points/${encodeURIComponent(object.ppId)}`, runId)}>
|
||||||
|
{object.ppId}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel flush>
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{ id: "parsed", label: "Parsed" },
|
||||||
|
{ id: "validation", label: "Validation" },
|
||||||
|
{ id: "chain", label: "Chain" },
|
||||||
|
]}
|
||||||
|
active={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
ariaLabel="Object detail sections"
|
||||||
|
/>
|
||||||
|
<TabPanel id="parsed" active={tab}>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
{projectionQuery.isPending ? (
|
||||||
|
<LoadingBlock label="Loading parsed projection…" />
|
||||||
|
) : projectionQuery.isError ? (
|
||||||
|
<ErrorBlock
|
||||||
|
error={projectionQuery.error}
|
||||||
|
onRetry={() => projectionQuery.refetch()}
|
||||||
|
title="Failed to load parsed projection"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProjectionView
|
||||||
|
record={projectionQuery.data ?? null}
|
||||||
|
runId={runId}
|
||||||
|
objectInstanceId={objectInstanceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="validation" active={tab}>
|
||||||
|
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="chain" active={tab}>
|
||||||
|
<ChainTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
|
</TabPanel>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal file
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { listObjects } from "../api/service";
|
||||||
|
import { Notice } from "../components/StateBlock";
|
||||||
|
import {
|
||||||
|
EMPTY_OBJECT_FILTERS,
|
||||||
|
ObjectsTable,
|
||||||
|
type ObjectFilterValues,
|
||||||
|
} from "../components/ObjectsTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
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() {
|
||||||
|
const { runId } = useRun();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
|
||||||
|
|
||||||
|
const setFilters = (next: ObjectFilterValues) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
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 =
|
||||||
|
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
||||||
|
filters.result !== "" ||
|
||||||
|
filters.rejectedOnly ||
|
||||||
|
filters.q !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Objects"
|
||||||
|
subtitle="Browse every object instance indexed for the active run. Lists stream from the run report — deep paging on large runs can be slow."
|
||||||
|
/>
|
||||||
|
{!hasFilters ? (
|
||||||
|
<Notice kind="info">
|
||||||
|
This run may contain hundreds of thousands of objects. Use the filters to
|
||||||
|
narrow the scan; all filters are applied server-side.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
<Panel title="All objects" flush>
|
||||||
|
<ObjectsTable
|
||||||
|
runId={runId}
|
||||||
|
queryKeyScope="global"
|
||||||
|
fetcher={(f) => listObjects(runId, f)}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
ui/rpki-explorer/src/pages/OverviewPage.tsx
Normal file
381
ui/rpki-explorer/src/pages/OverviewPage.tsx
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Cell,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
} from "recharts";
|
||||||
|
import { Clock, GitBranch, Timer } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getStatsObjectTypes,
|
||||||
|
getStatsReasons,
|
||||||
|
getStatsValidation,
|
||||||
|
listRepos,
|
||||||
|
} from "../api/service";
|
||||||
|
import type { RepositoryRecord } from "../api/schemas";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { KpiCard } from "../components/KpiCard";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||||
|
import {
|
||||||
|
formatDurationMs,
|
||||||
|
formatInt,
|
||||||
|
formatPercent,
|
||||||
|
formatRelative,
|
||||||
|
formatUtc,
|
||||||
|
objectTypeLabel,
|
||||||
|
truncateMiddle,
|
||||||
|
} from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
/** Semantic colors keyed by validation result — never assigned by index. */
|
||||||
|
const RESULT_COLORS: Record<string, string> = {
|
||||||
|
ok: "#16a34a",
|
||||||
|
valid: "#16a34a",
|
||||||
|
warnings: "#d97706",
|
||||||
|
warning: "#d97706",
|
||||||
|
error: "#dc2626",
|
||||||
|
invalid: "#dc2626",
|
||||||
|
rejected: "#dc2626",
|
||||||
|
skipped: "#94a3b8",
|
||||||
|
unknown: "#94a3b8",
|
||||||
|
};
|
||||||
|
const FALLBACK_COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#8b5cf6", "#64748b"];
|
||||||
|
const TYPE_PALETTE = ["#1d4ed8", "#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#0ea5e9", "#38bdf8", "#7dd3fc"];
|
||||||
|
|
||||||
|
function colorForResult(name: string, index: number): string {
|
||||||
|
return RESULT_COLORS[name.toLowerCase()] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const { runId, runQuery } = useRun();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "validation"],
|
||||||
|
queryFn: () => getStatsValidation(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "object-types"],
|
||||||
|
queryFn: () => getStatsObjectTypes(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "reasons"],
|
||||||
|
queryFn: () => getStatsReasons(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["repos", runId, "top"],
|
||||||
|
queryFn: () => listRepos(runId, { limit: 8 }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = runQuery.data;
|
||||||
|
const counts = run?.counts;
|
||||||
|
|
||||||
|
const validationData = useMemo(() => {
|
||||||
|
const map = validationQuery.data ?? {};
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
}, [validationQuery.data]);
|
||||||
|
|
||||||
|
const validationTotal = useMemo(
|
||||||
|
() => validationData.reduce((sum, entry) => sum + entry.value, 0),
|
||||||
|
[validationData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const typeData = useMemo(() => {
|
||||||
|
const map = typesQuery.data ?? {};
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([name, value]) => ({ name: objectTypeLabel(name), value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [typesQuery.data]);
|
||||||
|
|
||||||
|
const reasonRows = useMemo(() => {
|
||||||
|
const map = reasonsQuery.data ?? {};
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([reason, count]) => ({ reason, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [reasonsQuery.data]);
|
||||||
|
|
||||||
|
const maxReason = reasonRows[0]?.count ?? 0;
|
||||||
|
|
||||||
|
if (runQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Overview" />
|
||||||
|
<ErrorBlock
|
||||||
|
error={runQuery.error}
|
||||||
|
onRetry={() => runQuery.refetch()}
|
||||||
|
title="Failed to load the latest run"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoColumns: Column<RepositoryRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "host",
|
||||||
|
header: "Repository",
|
||||||
|
render: (repo) => (
|
||||||
|
<div>
|
||||||
|
<div className="cell-main">{repo.host}</div>
|
||||||
|
<div className="text-faint text-small mono ellipsis" style={{ maxWidth: 260 }}>
|
||||||
|
{truncateMiddle(repo.uri, 56)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transport",
|
||||||
|
header: "Transport",
|
||||||
|
render: (repo) => <span className="chip">{repo.transport ?? "unknown"}</span>,
|
||||||
|
},
|
||||||
|
{ key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) },
|
||||||
|
{
|
||||||
|
key: "rejected",
|
||||||
|
header: "Rejected",
|
||||||
|
numeric: true,
|
||||||
|
render: (repo) =>
|
||||||
|
repo.rejectedObjects ? (
|
||||||
|
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>
|
||||||
|
{formatInt(repo.rejectedObjects)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: "Sync time",
|
||||||
|
numeric: true,
|
||||||
|
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Overview"
|
||||||
|
subtitle={
|
||||||
|
run ? (
|
||||||
|
<span className="overview-run-strip">
|
||||||
|
<span className="run-strip-item mono">{run.runId}</span>
|
||||||
|
<span className="run-strip-item">
|
||||||
|
<Clock size={13} aria-hidden="true" />
|
||||||
|
<span title={formatUtc(run.validationTime)}>
|
||||||
|
validated {formatRelative(run.validationTime)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{run.syncMode ? (
|
||||||
|
<span className="run-strip-item">
|
||||||
|
<GitBranch size={13} aria-hidden="true" /> {run.syncMode} sync
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="run-strip-item">
|
||||||
|
<Timer size={13} aria-hidden="true" /> wall {formatDurationMs(run.wallMs)}
|
||||||
|
</span>
|
||||||
|
{run.indexStatus ? <StatusPill status={run.indexStatus} label={`index ${run.indexStatus}`} /> : null}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{runQuery.isPending ? (
|
||||||
|
<LoadingBlock label="Loading latest run…" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard label="VRPs" value={formatInt(counts?.vrps)} to={withRunParam("/objects?type=roa", runId)} />
|
||||||
|
<KpiCard label="ASPAs" value={formatInt(counts?.aspas)} to={withRunParam("/objects?type=aspa", runId)} />
|
||||||
|
<KpiCard label="Objects" value={formatInt(counts?.objects)} to={withRunParam("/objects", runId)} />
|
||||||
|
<KpiCard
|
||||||
|
label="Publication points"
|
||||||
|
value={formatInt(counts?.publicationPoints)}
|
||||||
|
to={withRunParam("/publication-points", runId)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Rejected"
|
||||||
|
value={formatInt(counts?.rejectedObjects)}
|
||||||
|
tone="red"
|
||||||
|
to={withRunParam("/validation", runId)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Warnings"
|
||||||
|
value={formatInt(counts?.warnings)}
|
||||||
|
tone="amber"
|
||||||
|
sub="objects with warnings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overview-charts">
|
||||||
|
<Panel title="Validation results" subtitle="Object audit results for this run">
|
||||||
|
{validationQuery.isError ? (
|
||||||
|
<ErrorBlock error={validationQuery.error} onRetry={() => validationQuery.refetch()} />
|
||||||
|
) : validationQuery.isPending ? (
|
||||||
|
<LoadingBlock />
|
||||||
|
) : validationData.length === 0 ? (
|
||||||
|
<p className="text-muted">No validation stats recorded.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="chart-box">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={validationData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius="58%"
|
||||||
|
outerRadius="88%"
|
||||||
|
paddingAngle={2}
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{validationData.map((entry, index) => (
|
||||||
|
<Cell key={entry.name} fill={colorForResult(entry.name, index)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [
|
||||||
|
`${formatInt(Number(value))} (${formatPercent(Number(value), validationTotal)})`,
|
||||||
|
String(name),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="chart-legend">
|
||||||
|
{validationData.map((entry, index) => (
|
||||||
|
<span className="legend-item" key={entry.name}>
|
||||||
|
<span
|
||||||
|
className="legend-swatch"
|
||||||
|
style={{ background: colorForResult(entry.name, index) }}
|
||||||
|
/>
|
||||||
|
{entry.name} · {formatInt(entry.value)} ({formatPercent(entry.value, validationTotal)})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Object types" subtitle="Objects by parsed type">
|
||||||
|
{typesQuery.isError ? (
|
||||||
|
<ErrorBlock error={typesQuery.error} onRetry={() => typesQuery.refetch()} />
|
||||||
|
) : typesQuery.isPending ? (
|
||||||
|
<LoadingBlock />
|
||||||
|
) : typeData.length === 0 ? (
|
||||||
|
<p className="text-muted">No object type stats recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="chart-box">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
||||||
|
<XAxis type="number" hide />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={104}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
||||||
|
/>
|
||||||
|
<Tooltip formatter={(value) => formatInt(Number(value))} />
|
||||||
|
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={16}>
|
||||||
|
{typeData.map((entry, index) => (
|
||||||
|
<Cell key={entry.name} fill={TYPE_PALETTE[index % TYPE_PALETTE.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overview-bottom">
|
||||||
|
<Panel
|
||||||
|
title="Top repositories"
|
||||||
|
subtitle="First repositories by index order"
|
||||||
|
tools={
|
||||||
|
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
{reposQuery.isError ? (
|
||||||
|
<div className="panel-body">
|
||||||
|
<ErrorBlock error={reposQuery.error} onRetry={() => reposQuery.refetch()} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={repoColumns}
|
||||||
|
rows={reposQuery.data?.items ?? []}
|
||||||
|
rowKey={(repo) => repo.repoId}
|
||||||
|
loading={reposQuery.isPending}
|
||||||
|
emptyTitle="No repositories indexed"
|
||||||
|
onRowClick={(repo) =>
|
||||||
|
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="Top reject reasons"
|
||||||
|
subtitle="Click a reason to inspect matching objects"
|
||||||
|
tools={
|
||||||
|
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
||||||
|
Validation
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reasonsQuery.isError ? (
|
||||||
|
<ErrorBlock error={reasonsQuery.error} onRetry={() => reasonsQuery.refetch()} />
|
||||||
|
) : reasonsQuery.isPending ? (
|
||||||
|
<LoadingBlock />
|
||||||
|
) : reasonRows.length === 0 ? (
|
||||||
|
<p className="text-muted">No rejected objects in this run.</p>
|
||||||
|
) : (
|
||||||
|
<div className="reason-list">
|
||||||
|
{reasonRows.map((row) => (
|
||||||
|
<Link
|
||||||
|
key={row.reason}
|
||||||
|
className="reason-row"
|
||||||
|
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
|
||||||
|
title={row.reason}
|
||||||
|
>
|
||||||
|
<span className="reason-text">{row.reason}</span>
|
||||||
|
<span className="reason-count">{formatInt(row.count)}</span>
|
||||||
|
<span className="reason-bar" aria-hidden="true">
|
||||||
|
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx
Normal file
115
ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getPublicationPoint,
|
||||||
|
listPublicationPointObjects,
|
||||||
|
} from "../api/service";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import { KpiCard } from "../components/KpiCard";
|
||||||
|
import {
|
||||||
|
EMPTY_OBJECT_FILTERS,
|
||||||
|
ObjectsTable,
|
||||||
|
type ObjectFilterValues,
|
||||||
|
} from "../components/ObjectsTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||||
|
import { formatDurationMs, formatInt, formatUtc } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
export default function PublicationPointDetailPage() {
|
||||||
|
const { ppId = "" } = useParams();
|
||||||
|
const { runId } = useRun();
|
||||||
|
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
|
||||||
|
|
||||||
|
const ppQuery = useQuery({
|
||||||
|
queryKey: ["pp", runId, ppId],
|
||||||
|
queryFn: () => getPublicationPoint(runId, ppId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ppQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Publication point" />
|
||||||
|
<ErrorBlock error={ppQuery.error} onRetry={() => ppQuery.refetch()} title="Failed to load publication point" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ppQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Publication point" />
|
||||||
|
<LoadingBlock />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pp = ppQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||||
|
<Link to={withRunParam("/publication-points", runId)}>Publication Points</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span className="mono">{pp.ppId}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={<span className="mono" style={{ fontSize: 16 }}>{pp.manifestRsyncUri ?? pp.ppId}</span>}
|
||||||
|
subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined}
|
||||||
|
actions={<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard label="Objects" value={formatInt(pp.objects)} />
|
||||||
|
<KpiCard label="Rejected" value={formatInt(pp.rejectedObjects)} tone="red" />
|
||||||
|
<KpiCard label="Warnings" value={formatInt(pp.warnings)} tone="amber" />
|
||||||
|
<KpiCard label="Sync time" value={formatDurationMs(pp.repoSyncDurationMs)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel title="Publication point details">
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>PP ID</dt>
|
||||||
|
<dd><CopyableValue value={pp.ppId} max={48} label="publication point id" /></dd>
|
||||||
|
<dt>Repository</dt>
|
||||||
|
<dd>
|
||||||
|
<Link to={withRunParam(`/repositories/${encodeURIComponent(pp.repoId)}`, runId)} className="mono">
|
||||||
|
{pp.repoId}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
<dt>Manifest URI</dt>
|
||||||
|
<dd><CopyableValue value={pp.manifestRsyncUri} max={96} label="manifest URI" /></dd>
|
||||||
|
<dt>PP rsync URI</dt>
|
||||||
|
<dd><CopyableValue value={pp.publicationPointRsyncUri} max={96} label="publication point rsync URI" /></dd>
|
||||||
|
<dt>rsync base</dt>
|
||||||
|
<dd><CopyableValue value={pp.rsyncBaseUri} max={96} label="rsync base URI" /></dd>
|
||||||
|
<dt>RRDP notification</dt>
|
||||||
|
<dd><CopyableValue value={pp.rrdpNotificationUri} max={96} label="RRDP notification URI" /></dd>
|
||||||
|
<dt>Sync source</dt>
|
||||||
|
<dd>{pp.repoSyncSource ?? pp.source ?? "—"}</dd>
|
||||||
|
<dt>Sync phase</dt>
|
||||||
|
<dd>{pp.repoSyncPhase ?? "—"}</dd>
|
||||||
|
<dt>Terminal state</dt>
|
||||||
|
<dd><StatusPill status={pp.repoTerminalState} /></dd>
|
||||||
|
<dt>thisUpdate</dt>
|
||||||
|
<dd>{formatUtc(pp.thisUpdate)}</dd>
|
||||||
|
<dt>nextUpdate</dt>
|
||||||
|
<dd>{formatUtc(pp.nextUpdate)}</dd>
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Objects" flush>
|
||||||
|
<ObjectsTable
|
||||||
|
runId={runId}
|
||||||
|
queryKeyScope={`pp:${ppId}`}
|
||||||
|
fetcher={(filters) => listPublicationPointObjects(runId, ppId, filters)}
|
||||||
|
filters={objectFilters}
|
||||||
|
onFiltersChange={setObjectFilters}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
ui/rpki-explorer/src/pages/PublicationPointsPage.tsx
Normal file
125
ui/rpki-explorer/src/pages/PublicationPointsPage.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { listPublicationPoints } from "../api/service";
|
||||||
|
import type { PublicationPointRecord } from "../api/schemas";
|
||||||
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatDurationMs, formatInt, truncateMiddle } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
export default function PublicationPointsPage() {
|
||||||
|
const { runId } = useRun();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pager = useCursorPager(runId);
|
||||||
|
const [needle, setNeedle] = useState("");
|
||||||
|
|
||||||
|
const ppsQuery = useQuery({
|
||||||
|
queryKey: ["pps", runId, pager.cursor],
|
||||||
|
queryFn: () => listPublicationPoints(runId, { limit: 50, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const items = ppsQuery.data?.items ?? [];
|
||||||
|
const q = needle.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter((pp) =>
|
||||||
|
[pp.manifestRsyncUri, pp.publicationPointRsyncUri, pp.rrdpNotificationUri, pp.rsyncBaseUri, pp.ppId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value!.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}, [ppsQuery.data, needle]);
|
||||||
|
|
||||||
|
const columns: Column<PublicationPointRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "manifest",
|
||||||
|
header: "Publication point",
|
||||||
|
render: (pp) => (
|
||||||
|
<div>
|
||||||
|
<div className="cell-main mono text-small ellipsis" style={{ maxWidth: 380 }} title={pp.manifestRsyncUri ?? pp.ppId}>
|
||||||
|
{pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 68) : pp.ppId}
|
||||||
|
</div>
|
||||||
|
<div className="text-faint text-small mono">{pp.ppId}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Sync source",
|
||||||
|
render: (pp) => <span className="chip">{pp.repoSyncSource ?? pp.source ?? "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
header: "Terminal state",
|
||||||
|
render: (pp) => <StatusPill status={pp.repoTerminalState} />,
|
||||||
|
},
|
||||||
|
{ key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) },
|
||||||
|
{
|
||||||
|
key: "rejected",
|
||||||
|
header: "Rejected",
|
||||||
|
numeric: true,
|
||||||
|
render: (pp) =>
|
||||||
|
pp.rejectedObjects ? (
|
||||||
|
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(pp.rejectedObjects)}</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "warnings", header: "Warnings", numeric: true, render: (pp) => formatInt(pp.warnings) },
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: "Sync time",
|
||||||
|
numeric: true,
|
||||||
|
render: (pp) => formatDurationMs(pp.repoSyncDurationMs),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Publication Points" subtitle="All publication points indexed for the active run" />
|
||||||
|
<Panel
|
||||||
|
title="All publication points"
|
||||||
|
tools={
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={needle}
|
||||||
|
onChange={(e) => setNeedle(e.target.value)}
|
||||||
|
placeholder="Filter current page (URI/id)…"
|
||||||
|
aria-label="Filter current page"
|
||||||
|
style={{ minWidth: 220, padding: "5px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-m)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(pp) => pp.ppId}
|
||||||
|
loading={ppsQuery.isPending}
|
||||||
|
error={ppsQuery.isError ? ppsQuery.error : undefined}
|
||||||
|
onRetry={() => ppsQuery.refetch()}
|
||||||
|
emptyTitle="No publication points indexed"
|
||||||
|
caption="Publication points"
|
||||||
|
onRowClick={(pp) =>
|
||||||
|
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={ppsQuery.data?.nextCursor != null}
|
||||||
|
loading={ppsQuery.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(ppsQuery.data?.nextCursor ?? null)}
|
||||||
|
itemCount={rows.length}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
ui/rpki-explorer/src/pages/RepositoriesPage.tsx
Normal file
144
ui/rpki-explorer/src/pages/RepositoriesPage.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { listRepos } from "../api/service";
|
||||||
|
import type { RepositoryRecord } from "../api/schemas";
|
||||||
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatBytes, formatDurationMs, formatInt, truncateMiddle } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
export default function RepositoriesPage() {
|
||||||
|
const { runId, runQuery } = useRun();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pager = useCursorPager(runId);
|
||||||
|
const [needle, setNeedle] = useState("");
|
||||||
|
|
||||||
|
const reposQuery = useQuery({
|
||||||
|
queryKey: ["repos", runId, pager.cursor],
|
||||||
|
queryFn: () => listRepos(runId, { limit: 50, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const items = reposQuery.data?.items ?? [];
|
||||||
|
const q = needle.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter(
|
||||||
|
(repo) =>
|
||||||
|
repo.host.toLowerCase().includes(q) || repo.uri.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [reposQuery.data, needle]);
|
||||||
|
|
||||||
|
const columns: Column<RepositoryRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "host",
|
||||||
|
header: "Repository",
|
||||||
|
render: (repo) => (
|
||||||
|
<div>
|
||||||
|
<div className="cell-main">{repo.host}</div>
|
||||||
|
<div className="text-faint text-small mono ellipsis" style={{ maxWidth: 320 }} title={repo.uri}>
|
||||||
|
{truncateMiddle(repo.uri, 64)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transport",
|
||||||
|
header: "Transport",
|
||||||
|
render: (repo) => <span className="chip">{repo.transport ?? "unknown"}</span>,
|
||||||
|
},
|
||||||
|
{ key: "pps", header: "PPs", numeric: true, render: (repo) => formatInt(repo.publicationPoints) },
|
||||||
|
{ key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) },
|
||||||
|
{
|
||||||
|
key: "rejected",
|
||||||
|
header: "Rejected",
|
||||||
|
numeric: true,
|
||||||
|
render: (repo) =>
|
||||||
|
repo.rejectedObjects ? (
|
||||||
|
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(repo.rejectedObjects)}</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "bytes", header: "Downloaded", numeric: true, render: (repo) => formatBytes(repo.downloadBytes) },
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: "Sync time",
|
||||||
|
numeric: true,
|
||||||
|
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 (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Repositories"
|
||||||
|
subtitle={
|
||||||
|
runQuery.data
|
||||||
|
? `${formatInt(runQuery.data.counts?.objects)} objects across all repositories in ${runQuery.data.runId}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
title="All repositories"
|
||||||
|
tools={
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor="repo-filter" className="sr-only">
|
||||||
|
Filter current page
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="repo-filter"
|
||||||
|
type="search"
|
||||||
|
value={needle}
|
||||||
|
onChange={(e) => setNeedle(e.target.value)}
|
||||||
|
placeholder="Filter current page (host/URI)…"
|
||||||
|
style={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(repo) => repo.repoId}
|
||||||
|
loading={reposQuery.isPending}
|
||||||
|
error={reposQuery.isError ? reposQuery.error : undefined}
|
||||||
|
onRetry={() => reposQuery.refetch()}
|
||||||
|
emptyTitle="No repositories indexed"
|
||||||
|
emptyHint="The query service has not indexed any run yet."
|
||||||
|
caption="Repositories"
|
||||||
|
onRowClick={(repo) =>
|
||||||
|
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={reposQuery.data?.nextCursor != null}
|
||||||
|
loading={reposQuery.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(reposQuery.data?.nextCursor ?? null)}
|
||||||
|
itemCount={rows.length}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx
Normal file
228
ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getRepo,
|
||||||
|
getRepoStats,
|
||||||
|
listRepoObjects,
|
||||||
|
listRepoPublicationPoints,
|
||||||
|
} from "../api/service";
|
||||||
|
import type { PublicationPointRecord } from "../api/schemas";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { KpiCard } from "../components/KpiCard";
|
||||||
|
import {
|
||||||
|
EMPTY_OBJECT_FILTERS,
|
||||||
|
ObjectsTable,
|
||||||
|
type ObjectFilterValues,
|
||||||
|
} from "../components/ObjectsTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { TabPanel, Tabs } from "../components/Tabs";
|
||||||
|
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pager = useCursorPager(`${runId}:${repoId}`);
|
||||||
|
const ppsQuery = useQuery({
|
||||||
|
queryKey: ["repo-pps", runId, repoId, pager.cursor],
|
||||||
|
queryFn: () => listRepoPublicationPoints(runId, repoId, { limit: 50, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<PublicationPointRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "manifest",
|
||||||
|
header: "Manifest URI",
|
||||||
|
render: (pp) => (
|
||||||
|
<span className="mono text-small ellipsis" style={{ display: "block", maxWidth: 420 }} title={pp.manifestRsyncUri ?? undefined}>
|
||||||
|
{pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 72) : pp.ppId}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Sync source",
|
||||||
|
render: (pp) => <span className="chip">{pp.repoSyncSource ?? pp.source ?? "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "state",
|
||||||
|
header: "Terminal state",
|
||||||
|
render: (pp) => <StatusPill status={pp.repoTerminalState} />,
|
||||||
|
},
|
||||||
|
{ key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) },
|
||||||
|
{
|
||||||
|
key: "rejected",
|
||||||
|
header: "Rejected",
|
||||||
|
numeric: true,
|
||||||
|
render: (pp) =>
|
||||||
|
pp.rejectedObjects ? (
|
||||||
|
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(pp.rejectedObjects)}</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "duration",
|
||||||
|
header: "Sync time",
|
||||||
|
numeric: true,
|
||||||
|
render: (pp) => formatDurationMs(pp.repoSyncDurationMs),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={ppsQuery.data?.items ?? []}
|
||||||
|
rowKey={(pp) => pp.ppId}
|
||||||
|
loading={ppsQuery.isPending}
|
||||||
|
error={ppsQuery.isError ? ppsQuery.error : undefined}
|
||||||
|
onRetry={() => ppsQuery.refetch()}
|
||||||
|
emptyTitle="No publication points"
|
||||||
|
caption="Publication points in this repository"
|
||||||
|
onRowClick={(pp) =>
|
||||||
|
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={ppsQuery.data?.nextCursor != null}
|
||||||
|
loading={ppsQuery.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(ppsQuery.data?.nextCursor ?? null)}
|
||||||
|
itemCount={ppsQuery.data?.items.length}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RepositoryDetailPage() {
|
||||||
|
const { repoId = "" } = useParams();
|
||||||
|
const { runId, runQuery } = useRun();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get("tab") ?? "pps";
|
||||||
|
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
|
||||||
|
|
||||||
|
const repoQuery = useQuery({
|
||||||
|
queryKey: ["repo", runId, repoId],
|
||||||
|
queryFn: () => getRepo(runId, repoId),
|
||||||
|
});
|
||||||
|
const statsQuery = useQuery({
|
||||||
|
queryKey: ["repo-stats", runId, repoId],
|
||||||
|
queryFn: () => getRepoStats(runId, repoId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (repoQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Repository" />
|
||||||
|
<ErrorBlock error={repoQuery.error} onRetry={() => repoQuery.refetch()} title="Failed to load repository" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (repoQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Repository" />
|
||||||
|
<LoadingBlock />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = repoQuery.data;
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
|
||||||
|
const setTab = (next: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("tab", next);
|
||||||
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||||
|
<Link to={withRunParam("/repositories", runId)}>Repositories</Link>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span>{repo.host}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={repo.host}
|
||||||
|
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
|
||||||
|
actions={<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
|
||||||
|
<KpiCard label="Objects" value={formatInt(repo.objects)} />
|
||||||
|
<KpiCard label="Rejected" value={formatInt(repo.rejectedObjects)} tone="red" />
|
||||||
|
<KpiCard label="Sync time" value={formatDurationMs(repo.syncDurationMsTotal)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel title="Repository details">
|
||||||
|
<dl className="meta-grid">
|
||||||
|
<dt>Repository ID</dt>
|
||||||
|
<dd><CopyableValue value={repo.repoId} max={48} label="repository id" /></dd>
|
||||||
|
<dt>URI</dt>
|
||||||
|
<dd><CopyableValue value={repo.uri} max={96} label="repository URI" /></dd>
|
||||||
|
<dt>Host</dt>
|
||||||
|
<dd>{repo.host}</dd>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd><span className="chip">{repo.transport ?? "unknown"}</span></dd>
|
||||||
|
<dt>Sync phases</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="chip-list">
|
||||||
|
{Object.entries(stats?.phases ?? repo.phases ?? {}).map(([phase, count]) => (
|
||||||
|
<span className="chip" key={phase}>{phase} {formatInt(count)}</span>
|
||||||
|
))}
|
||||||
|
{Object.keys(stats?.phases ?? repo.phases ?? {}).length === 0 && <span className="text-faint">—</span>}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dt>Terminal states</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="chip-list">
|
||||||
|
{Object.entries(stats?.terminalStates ?? repo.terminalStates ?? {}).map(([state, count]) => (
|
||||||
|
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
|
||||||
|
))}
|
||||||
|
{Object.keys(stats?.terminalStates ?? repo.terminalStates ?? {}).length === 0 && <span className="text-faint">—</span>}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
<dt>Run validated</dt>
|
||||||
|
<dd>{formatUtc(runQuery.data?.validationTime)}</dd>
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel flush>
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{ id: "pps", label: "Publication points" },
|
||||||
|
{ id: "objects", label: "Objects" },
|
||||||
|
]}
|
||||||
|
active={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
ariaLabel="Repository sections"
|
||||||
|
/>
|
||||||
|
<TabPanel id="pps" active={tab}>
|
||||||
|
<RepoPpsTable runId={runId} repoId={repoId} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="objects" active={tab}>
|
||||||
|
<ObjectsTable
|
||||||
|
runId={runId}
|
||||||
|
queryKeyScope={`repo:${repoId}`}
|
||||||
|
fetcher={(filters) => listRepoObjects(runId, repoId, filters)}
|
||||||
|
filters={objectFilters}
|
||||||
|
onFiltersChange={setObjectFilters}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
ui/rpki-explorer/src/pages/RunsPage.tsx
Normal file
145
ui/rpki-explorer/src/pages/RunsPage.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getRunArtifacts, listRuns } from "../api/service";
|
||||||
|
import type { RunRecord } from "../api/schemas";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatDurationMs, formatInt, formatRelative, formatUtc } from "../lib/format";
|
||||||
|
import { useRunParam } from "../lib/run";
|
||||||
|
|
||||||
|
function RunArtifacts({ runId }: { runId: string }) {
|
||||||
|
const artifactsQuery = useQuery({
|
||||||
|
queryKey: ["run-artifacts", runId],
|
||||||
|
queryFn: () => getRunArtifacts(runId),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artifactsQuery.isPending) return <div className="run-artifacts">Loading artifacts…</div>;
|
||||||
|
if (artifactsQuery.isError) {
|
||||||
|
return <div className="run-artifacts">Failed to load artifacts: {artifactsQuery.error.message}</div>;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(artifactsQuery.data).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
return (
|
||||||
|
<div className="run-artifacts">
|
||||||
|
<ul>
|
||||||
|
{entries.map(([name, path]) => (
|
||||||
|
<li key={name}>
|
||||||
|
<span className="text-muted">{name}</span>
|
||||||
|
<CopyableValue value={path} max={90} label={`artifact ${name}`} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RunsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const runParam = useRunParam();
|
||||||
|
const pager = useCursorPager("runs");
|
||||||
|
const [expandedRun, setExpandedRun] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const runsQuery = useQuery({
|
||||||
|
queryKey: ["runs", "page", pager.cursor],
|
||||||
|
queryFn: () => listRuns({ limit: 25, cursor: pager.cursor }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<RunRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "run",
|
||||||
|
header: "Run",
|
||||||
|
render: (run) => (
|
||||||
|
<div>
|
||||||
|
<div className="cell-main mono">{run.runId}</div>
|
||||||
|
<div className="text-faint text-small">seq {run.runSeq ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "validated",
|
||||||
|
header: "Validated",
|
||||||
|
render: (run) => <span title={formatUtc(run.validationTime)}>{formatRelative(run.validationTime)}</span>,
|
||||||
|
},
|
||||||
|
{ key: "mode", header: "Sync mode", render: (run) => <span className="chip">{run.syncMode ?? "—"}</span> },
|
||||||
|
{ key: "wall", header: "Wall time", numeric: true, render: (run) => formatDurationMs(run.wallMs) },
|
||||||
|
{ key: "objects", header: "Objects", numeric: true, render: (run) => formatInt(run.counts?.objects) },
|
||||||
|
{ key: "vrps", header: "VRPs", numeric: true, render: (run) => formatInt(run.counts?.vrps) },
|
||||||
|
{
|
||||||
|
key: "rejected",
|
||||||
|
header: "Rejected",
|
||||||
|
numeric: true,
|
||||||
|
render: (run) =>
|
||||||
|
run.counts?.rejectedObjects ? (
|
||||||
|
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(run.counts.rejectedObjects)}</span>
|
||||||
|
) : (
|
||||||
|
"0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "index", header: "Index", render: (run) => <StatusPill status={run.indexStatus} /> },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "",
|
||||||
|
render: (run) => {
|
||||||
|
const active = runParam === run.runId;
|
||||||
|
return (
|
||||||
|
<span style={{ display: "inline-flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
disabled={active}
|
||||||
|
onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)}
|
||||||
|
>
|
||||||
|
{active ? "Active" : "Use run"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))}
|
||||||
|
aria-expanded={expandedRun === run.runId}
|
||||||
|
>
|
||||||
|
Artifacts
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Runs"
|
||||||
|
subtitle="Validation runs indexed by the query service (retention: last 10 by default)"
|
||||||
|
/>
|
||||||
|
<Panel title="Run history" flush>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={runsQuery.data?.items ?? []}
|
||||||
|
rowKey={(run) => run.runId}
|
||||||
|
loading={runsQuery.isPending}
|
||||||
|
error={runsQuery.isError ? runsQuery.error : undefined}
|
||||||
|
onRetry={() => runsQuery.refetch()}
|
||||||
|
emptyTitle="No runs indexed"
|
||||||
|
caption="Validation runs"
|
||||||
|
/>
|
||||||
|
{expandedRun ? <RunArtifacts runId={expandedRun} /> : null}
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={runsQuery.data?.nextCursor != null}
|
||||||
|
loading={runsQuery.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(runsQuery.data?.nextCursor ?? null)}
|
||||||
|
itemCount={runsQuery.data?.items.length}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
ui/rpki-explorer/src/pages/SearchPage.tsx
Normal file
171
ui/rpki-explorer/src/pages/SearchPage.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { FileBox, FolderTree, Server } from "lucide-react";
|
||||||
|
import { searchRun } from "../api/service";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
|
||||||
|
import { objectTypeLabel } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
|
||||||
|
/** Detect queries the backend cannot serve yet (VRP lookup = backlog #070). */
|
||||||
|
function looksLikeNetworkQuery(q: string): boolean {
|
||||||
|
return (
|
||||||
|
/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix
|
||||||
|
/^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish
|
||||||
|
/^(AS)?\d{1,10}$/i.test(q) // ASN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const { runId } = useRun();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const [draft, setDraft] = useState(q);
|
||||||
|
|
||||||
|
useEffect(() => setDraft(q), [q]);
|
||||||
|
|
||||||
|
const searchQuery = useQuery({
|
||||||
|
queryKey: ["search", runId, q],
|
||||||
|
queryFn: () => searchRun(runId, q, 10),
|
||||||
|
enabled: q.trim().length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (draft.trim()) params.set("q", draft.trim());
|
||||||
|
else params.delete("q");
|
||||||
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = searchQuery.data;
|
||||||
|
const totalHits =
|
||||||
|
(result?.objects.length ?? 0) +
|
||||||
|
(result?.repos.length ?? 0) +
|
||||||
|
(result?.publicationPoints.length ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Search"
|
||||||
|
subtitle="Exact object URI, SHA-256 (full or ≥8 hex prefix), repository host, or publication point URI"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form className="search-hero" role="search" onSubmit={onSubmit}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="rsync://… or 64-char sha256 or repository host…"
|
||||||
|
aria-label="Search query"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn primary">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{q && looksLikeNetworkQuery(q) ? (
|
||||||
|
<Notice kind="info">
|
||||||
|
IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup),
|
||||||
|
which is not implemented yet. The results below only cover objects,
|
||||||
|
repositories and publication points.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!q ? (
|
||||||
|
<EmptyBlock
|
||||||
|
title="Enter a query"
|
||||||
|
hint="Paste an object URI to jump straight to its detail page, or a hash / host to find matching entities."
|
||||||
|
/>
|
||||||
|
) : searchQuery.isError ? (
|
||||||
|
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
|
||||||
|
) : searchQuery.isPending ? (
|
||||||
|
<EmptyBlock title="Searching…" />
|
||||||
|
) : !result || totalHits === 0 ? (
|
||||||
|
<EmptyBlock title={`No results for “${q}”`} hint="URI search is exact; hash search accepts a prefix of 8+ hex characters." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{result.objects.length > 0 ? (
|
||||||
|
<section className="result-group">
|
||||||
|
<h3>Objects ({result.objects.length})</h3>
|
||||||
|
{result.objects.map((obj) => (
|
||||||
|
<Link
|
||||||
|
key={obj.objectInstanceId}
|
||||||
|
className="result-item"
|
||||||
|
to={withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId)}
|
||||||
|
>
|
||||||
|
<span className="result-kind">
|
||||||
|
<FileBox size={14} aria-hidden="true" /> {objectTypeLabel(obj.objectType)}
|
||||||
|
</span>
|
||||||
|
<span className="result-main">
|
||||||
|
<span className="result-title">{obj.uri}</span>
|
||||||
|
<span className="result-sub mono">sha256 {obj.sha256 ?? "—"}</span>
|
||||||
|
</span>
|
||||||
|
<StatusPill status={obj.rejected ? "rejected" : obj.result} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result.repos.length > 0 ? (
|
||||||
|
<section className="result-group">
|
||||||
|
<h3>Repositories ({result.repos.length})</h3>
|
||||||
|
{result.repos.map((repo) => (
|
||||||
|
<Link
|
||||||
|
key={repo.repoId}
|
||||||
|
className="result-item"
|
||||||
|
to={withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId)}
|
||||||
|
>
|
||||||
|
<span className="result-kind">
|
||||||
|
<Server size={14} aria-hidden="true" /> repo
|
||||||
|
</span>
|
||||||
|
<span className="result-main">
|
||||||
|
<span className="result-title">{repo.host}</span>
|
||||||
|
<span className="result-sub mono">{repo.uri}</span>
|
||||||
|
</span>
|
||||||
|
<span className="chip">{repo.transport ?? "unknown"}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{result.publicationPoints.length > 0 ? (
|
||||||
|
<section className="result-group">
|
||||||
|
<h3>Publication points ({result.publicationPoints.length})</h3>
|
||||||
|
{result.publicationPoints.map((pp) => (
|
||||||
|
<Link
|
||||||
|
key={pp.ppId}
|
||||||
|
className="result-item"
|
||||||
|
to={withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId)}
|
||||||
|
>
|
||||||
|
<span className="result-kind">
|
||||||
|
<FolderTree size={14} aria-hidden="true" /> PP
|
||||||
|
</span>
|
||||||
|
<span className="result-main">
|
||||||
|
<span className="result-title">{pp.manifestRsyncUri ?? pp.ppId}</span>
|
||||||
|
<span className="result-sub mono">{pp.ppId}</span>
|
||||||
|
</span>
|
||||||
|
<StatusPill status={pp.repoTerminalState} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title="Search syntax">
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<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>any other text — substring match on repository host/URI and publication point URIs</li>
|
||||||
|
<li>IP / prefix / ASN VRP lookup is planned under backend feature #070</li>
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
ui/rpki-explorer/src/pages/ValidationPage.tsx
Normal file
272
ui/rpki-explorer/src/pages/ValidationPage.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getStatsReasons,
|
||||||
|
getStatsValidation,
|
||||||
|
listIssues,
|
||||||
|
listRepos,
|
||||||
|
} from "../api/service";
|
||||||
|
import { KpiCard } from "../components/KpiCard";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||||
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||||
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
|
import { CopyableValue } from "../components/CopyableValue";
|
||||||
|
import type { ObjectInstanceRecord } from "../api/schemas";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
|
import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format";
|
||||||
|
import { withRunParam } from "../lib/run";
|
||||||
|
import { useRun } from "../lib/useRun";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ValidationPage() {
|
||||||
|
const { runId } = useRun();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const reason = searchParams.get("reason") ?? "";
|
||||||
|
const type = searchParams.get("type") ?? "";
|
||||||
|
const repoId = searchParams.get("repoId") ?? "";
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify([runId, reason, type, repoId]);
|
||||||
|
const pager = useCursorPager(filterKey);
|
||||||
|
|
||||||
|
const [validationQuery, reasonsQuery, reposQuery] = useQueries({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "validation"],
|
||||||
|
queryFn: () => getStatsValidation(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "reasons"],
|
||||||
|
queryFn: () => getStatsReasons(runId),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["repos", runId, "filter-options"],
|
||||||
|
queryFn: () => listRepos(runId, { limit: 200 }),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuesQuery = useQuery({
|
||||||
|
queryKey: ["issues", runId, reason, type, repoId, pager.cursor],
|
||||||
|
queryFn: () =>
|
||||||
|
listIssues(runId, {
|
||||||
|
limit: 50,
|
||||||
|
cursor: pager.cursor,
|
||||||
|
reason: reason || undefined,
|
||||||
|
type: type || undefined,
|
||||||
|
repoId: repoId || undefined,
|
||||||
|
}),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validationEntries = useMemo(() => {
|
||||||
|
const map = validationQuery.data ?? {};
|
||||||
|
return Object.entries(map).sort((a, b) => b[1] - a[1]);
|
||||||
|
}, [validationQuery.data]);
|
||||||
|
const validationTotal = validationEntries.reduce((sum, [, n]) => sum + n, 0);
|
||||||
|
|
||||||
|
const reasonRows = useMemo(() => {
|
||||||
|
const map = reasonsQuery.data ?? {};
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([text, count]) => ({ text, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [reasonsQuery.data]);
|
||||||
|
const maxReason = reasonRows[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const setParam = (key: string, value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
else params.delete(key);
|
||||||
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<ObjectInstanceRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Type",
|
||||||
|
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "uri",
|
||||||
|
header: "URI",
|
||||||
|
render: (obj) => <CopyableValue value={obj.uri} max={60} label="object URI" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "result",
|
||||||
|
header: "Result",
|
||||||
|
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
header: "Reject reason",
|
||||||
|
render: (obj) =>
|
||||||
|
obj.rejectReason ? (
|
||||||
|
<span className="mono text-small" title={obj.rejectReason}>
|
||||||
|
{truncateMiddle(obj.rejectReason, 72)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-faint">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Source",
|
||||||
|
render: (obj) => <StatusPill status={obj.sourceSection} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title="Validation"
|
||||||
|
subtitle="Rejected and errored objects for the active run, with reason distribution"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="validation-summary">
|
||||||
|
<Panel title="Result distribution" subtitle="Share of audited objects">
|
||||||
|
{validationQuery.isError ? (
|
||||||
|
<ErrorBlock error={validationQuery.error} onRetry={() => validationQuery.refetch()} />
|
||||||
|
) : validationQuery.isPending ? (
|
||||||
|
<LoadingBlock />
|
||||||
|
) : validationEntries.length === 0 ? (
|
||||||
|
<p className="text-muted">No validation stats recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="reason-list">
|
||||||
|
{validationEntries.map(([name, count]) => (
|
||||||
|
<div className="reason-row" key={name}>
|
||||||
|
<span className="reason-text" style={{ fontFamily: "inherit" }}>
|
||||||
|
<StatusPill status={name} />
|
||||||
|
</span>
|
||||||
|
<span className="reason-count">
|
||||||
|
{formatInt(count)} · {formatPercent(count, validationTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Reject reasons" subtitle="Click to filter the issue list below">
|
||||||
|
{reasonsQuery.isError ? (
|
||||||
|
<ErrorBlock error={reasonsQuery.error} onRetry={() => reasonsQuery.refetch()} />
|
||||||
|
) : reasonsQuery.isPending ? (
|
||||||
|
<LoadingBlock />
|
||||||
|
) : reasonRows.length === 0 ? (
|
||||||
|
<p className="text-muted">No rejected objects in this run.</p>
|
||||||
|
) : (
|
||||||
|
<div className="reason-list">
|
||||||
|
{reasonRows.map((row) => (
|
||||||
|
<button
|
||||||
|
key={row.text}
|
||||||
|
type="button"
|
||||||
|
className="reason-row"
|
||||||
|
style={{ border: "none", background: "none", cursor: "pointer", textAlign: "left" }}
|
||||||
|
onClick={() => setParam("reason", row.text === reason ? "" : row.text)}
|
||||||
|
title={row.text}
|
||||||
|
aria-pressed={row.text === reason}
|
||||||
|
>
|
||||||
|
<span className="reason-text">{row.text}</span>
|
||||||
|
<span className="reason-count">{formatInt(row.count)}</span>
|
||||||
|
<span className="reason-bar" aria-hidden="true">
|
||||||
|
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="Validation issues"
|
||||||
|
subtitle="Objects that were rejected or failed validation"
|
||||||
|
tools={
|
||||||
|
<div className="filter-bar">
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor="issue-type">Type</label>
|
||||||
|
<select id="issue-type" value={type} onChange={(e) => setParam("type", e.target.value)}>
|
||||||
|
<option value="">all</option>
|
||||||
|
{["roa", "manifest", "crl", "certificate", "aspa", "gbr"].map((t) => (
|
||||||
|
<option key={t} value={t}>{objectTypeLabel(t)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor="issue-repo">Repository</label>
|
||||||
|
<select id="issue-repo" value={repoId} onChange={(e) => setParam("repoId", e.target.value)}>
|
||||||
|
<option value="">all</option>
|
||||||
|
{(reposQuery.data?.items ?? []).map((repo) => (
|
||||||
|
<option key={repo.repoId} value={repo.repoId}>
|
||||||
|
{repo.host}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-field">
|
||||||
|
<label htmlFor="issue-reason">Reason contains</label>
|
||||||
|
<input
|
||||||
|
id="issue-reason"
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setParam("reason", e.target.value)}
|
||||||
|
placeholder="substring…"
|
||||||
|
className="mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(reason || type || repoId) && (
|
||||||
|
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
||||||
|
Clear filters
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={issuesQuery.data?.items ?? []}
|
||||||
|
rowKey={(obj) => obj.objectInstanceId}
|
||||||
|
loading={issuesQuery.isPending}
|
||||||
|
error={issuesQuery.isError ? issuesQuery.error : undefined}
|
||||||
|
onRetry={() => issuesQuery.refetch()}
|
||||||
|
emptyTitle="No validation issues"
|
||||||
|
emptyHint="Nothing was rejected or errored in this run (or filters are too strict)."
|
||||||
|
caption="Validation issues"
|
||||||
|
onRowClick={(obj) =>
|
||||||
|
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CursorPagerControls
|
||||||
|
page={pager.page}
|
||||||
|
canPrev={pager.canPrev}
|
||||||
|
hasNext={issuesQuery.data?.nextCursor != null}
|
||||||
|
loading={issuesQuery.isFetching}
|
||||||
|
onPrev={pager.goPrev}
|
||||||
|
onNext={() => pager.goNext(issuesQuery.data?.nextCursor ?? null)}
|
||||||
|
itemCount={issuesQuery.data?.items.length}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
<KpiCard
|
||||||
|
label="Issues on this page"
|
||||||
|
value={formatInt(issuesQuery.data?.items.length)}
|
||||||
|
tone="red"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Distinct reasons"
|
||||||
|
value={formatInt(Object.keys(reasonsQuery.data ?? {}).length)}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
ui/rpki-explorer/src/styles/base.css
Normal file
108
ui/rpki-explorer/src/styles/base.css
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/* Reset and base element styles. */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-1);
|
||||||
|
background: var(--bg-0);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--blue-600);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--blue-500);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-faint {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--blue-100);
|
||||||
|
}
|
||||||
603
ui/rpki-explorer/src/styles/components.css
Normal file
603
ui/rpki-explorer/src/styles/components.css
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
/* Shared UI components: cards, tables, pills, buttons, banners, tabs, forms. */
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .panel-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .panel-tools {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body.flush {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--bg-1);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--blue-600);
|
||||||
|
border-color: var(--blue-600);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background: var(--blue-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
color: var(--red-700);
|
||||||
|
border-color: var(--red-100);
|
||||||
|
background: var(--red-050);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.small {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill .pill-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.tone-green {
|
||||||
|
background: var(--green-050);
|
||||||
|
color: var(--green-700);
|
||||||
|
border-color: var(--green-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.tone-amber {
|
||||||
|
background: var(--amber-050);
|
||||||
|
color: var(--amber-700);
|
||||||
|
border-color: var(--amber-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.tone-red {
|
||||||
|
background: var(--red-050);
|
||||||
|
color: var(--red-700);
|
||||||
|
border-color: var(--red-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.tone-blue {
|
||||||
|
background: var(--blue-050);
|
||||||
|
color: var(--blue-700);
|
||||||
|
border-color: var(--blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.tone-slate {
|
||||||
|
background: var(--slate-100);
|
||||||
|
color: var(--text-2);
|
||||||
|
border-color: var(--slate-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 0 0 var(--radius-l) var(--radius-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
table.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 8px var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 7px var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr.clickable:hover {
|
||||||
|
background: var(--blue-050);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td.num,
|
||||||
|
.data-table th.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table .cell-main {
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-state {
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI cards */
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 680;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card .kpi-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card.tone-red .kpi-value {
|
||||||
|
color: var(--red-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card.tone-amber .kpi-value {
|
||||||
|
color: var(--amber-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card.tone-blue .kpi-value {
|
||||||
|
color: var(--blue-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
a.kpi-card:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
border-color: var(--blue-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banners and state blocks */
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 12.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
background: var(--red-050);
|
||||||
|
border-color: var(--red-100);
|
||||||
|
color: var(--red-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.warning {
|
||||||
|
background: var(--amber-050);
|
||||||
|
border-color: var(--amber-100);
|
||||||
|
color: var(--amber-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.info {
|
||||||
|
background: var(--blue-050);
|
||||||
|
border-color: var(--blue-100);
|
||||||
|
color: var(--blue-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner .banner-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
color: var(--text-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-block .state-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--blue-500);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner.small {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs [role="tab"] {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 550;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs [role="tab"]:hover {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs [role="tab"][aria-selected="true"] {
|
||||||
|
color: var(--blue-700);
|
||||||
|
border-bottom-color: var(--blue-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field > label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field input[type="text"],
|
||||||
|
.filter-field input[type="search"],
|
||||||
|
.filter-field select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--bg-1);
|
||||||
|
font-size: 12.5px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field input.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pager */
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .pager-info {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Definition list for metadata */
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(140px, max-content) minmax(0, 1fr);
|
||||||
|
gap: 6px var(--space-4);
|
||||||
|
font-size: 12.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dt {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 550;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copyable value */
|
||||||
|
.copyable {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable .copyable-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.94em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable .copy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 2px;
|
||||||
|
color: var(--text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable .copy-btn:hover {
|
||||||
|
color: var(--blue-600);
|
||||||
|
background: var(--blue-050);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable .copy-btn.copied {
|
||||||
|
color: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--slate-100);
|
||||||
|
border: 1px solid var(--slate-200);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JSON block */
|
||||||
|
.json-block {
|
||||||
|
background: var(--text-1);
|
||||||
|
color: #d7e0ee;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading rows */
|
||||||
|
.skeleton-row {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 50%, var(--bg-2) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s infinite;
|
||||||
|
margin: 10px var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow (async job) status line */
|
||||||
|
.workflow-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search result groups */
|
||||||
|
.result-group h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 8px var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--bg-1);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
border-color: var(--blue-500);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .result-kind {
|
||||||
|
flex: none;
|
||||||
|
width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .result-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .result-title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.3px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .result-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
217
ui/rpki-explorer/src/styles/pages.css
Normal file
217
ui/rpki-explorer/src/styles/pages.css
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
/* Page-specific layouts. */
|
||||||
|
|
||||||
|
/* Overview */
|
||||||
|
.overview-run-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-run-strip .run-strip-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.overview-charts,
|
||||||
|
.overview-bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 0 var(--space-4) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend .legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend .legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reason list (overview + validation) */
|
||||||
|
.reason-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
gap: 4px var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reason-row:hover {
|
||||||
|
background: var(--blue-050);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-row .reason-text {
|
||||||
|
font-size: 12.3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-row .reason-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-row .reason-bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-row .reason-bar > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--amber-600);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-column detail layout */
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Object detail header card */
|
||||||
|
.object-header-card .meta-grid {
|
||||||
|
grid-template-columns: minmax(110px, max-content) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Runs page */
|
||||||
|
.run-artifacts {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-artifacts ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-artifacts li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 200px) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API page */
|
||||||
|
.endpoint-table td:first-child {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-table .method {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--blue-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-table .method.post {
|
||||||
|
color: var(--amber-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search page */
|
||||||
|
.search-hero {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hero input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Validation page */
|
||||||
|
.validation-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.validation-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not found */
|
||||||
|
.not-found {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found .not-found-code {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
316
ui/rpki-explorer/src/styles/shell.css
Normal file
316
ui/rpki-explorer/src/styles/shell.css
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
/* Application shell: sidebar navigation + topbar + content area. */
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||||
|
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"brand topbar"
|
||||||
|
"nav main";
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.collapsed {
|
||||||
|
grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-brand {
|
||||||
|
grid-area: brand;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--text-1);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-brand .brand-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--blue-500);
|
||||||
|
color: #fff;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-brand .brand-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.collapsed .brand-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar {
|
||||||
|
grid-area: topbar;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-search {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
max-width: 520px;
|
||||||
|
min-width: 120px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px 6px 30px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--bg-0);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-search input:focus {
|
||||||
|
background: var(--bg-1);
|
||||||
|
border-color: var(--blue-500);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--blue-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-search .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-3);
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-run {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-run label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-run select {
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--bg-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
grid-area: nav;
|
||||||
|
background: var(--text-1);
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: var(--space-3) var(--space-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav .nav-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav .nav-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a.active {
|
||||||
|
background: var(--blue-600);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a .nav-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.collapsed .shell-nav a {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.collapsed .shell-nav a .nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav .nav-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--space-2) 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7d8aa0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell.collapsed .nav-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
grid-area: main;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-title-block {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-subtitle {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell,
|
||||||
|
.shell.collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: var(--topbar-height) auto auto minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"brand"
|
||||||
|
"topbar"
|
||||||
|
"nav"
|
||||||
|
"main";
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-brand .brand-name {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-search {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-run {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-topbar .topbar-run select {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav .nav-toggle,
|
||||||
|
.shell-nav .nav-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav a .nav-label,
|
||||||
|
.shell.collapsed .shell-nav a .nav-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
ui/rpki-explorer/src/styles/tokens.css
Normal file
67
ui/rpki-explorer/src/styles/tokens.css
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/* Design tokens for RPKI Explorer — data-dense dashboard theme. */
|
||||||
|
:root {
|
||||||
|
--font-sans:
|
||||||
|
"Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial,
|
||||||
|
sans-serif;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
|
||||||
|
--text-1: #0f172a;
|
||||||
|
--text-2: #475569;
|
||||||
|
--text-3: #8a97a8;
|
||||||
|
|
||||||
|
--bg-0: #f4f6fa;
|
||||||
|
--bg-1: #ffffff;
|
||||||
|
--bg-2: #edf1f7;
|
||||||
|
--bg-3: #e3e9f2;
|
||||||
|
|
||||||
|
--border: #dbe2ec;
|
||||||
|
--border-strong: #c2ccd9;
|
||||||
|
|
||||||
|
--blue-700: #1e40af;
|
||||||
|
--blue-600: #1d4ed8;
|
||||||
|
--blue-500: #2563eb;
|
||||||
|
--blue-100: #dbeafe;
|
||||||
|
--blue-050: #eff6ff;
|
||||||
|
|
||||||
|
--amber-700: #b45309;
|
||||||
|
--amber-600: #d97706;
|
||||||
|
--amber-100: #fef3c7;
|
||||||
|
--amber-050: #fffbeb;
|
||||||
|
|
||||||
|
--green-700: #15803d;
|
||||||
|
--green-600: #16a34a;
|
||||||
|
--green-100: #dcfce7;
|
||||||
|
--green-050: #f0fdf4;
|
||||||
|
|
||||||
|
--red-700: #b91c1c;
|
||||||
|
--red-600: #dc2626;
|
||||||
|
--red-100: #fee2e2;
|
||||||
|
--red-050: #fef2f2;
|
||||||
|
|
||||||
|
--slate-100: #f1f5f9;
|
||||||
|
--slate-200: #e2e8f0;
|
||||||
|
|
||||||
|
--radius-s: 4px;
|
||||||
|
--radius-m: 8px;
|
||||||
|
--radius-l: 12px;
|
||||||
|
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
|
||||||
|
--shadow-1: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-2: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||||
|
|
||||||
|
--topbar-height: 52px;
|
||||||
|
--sidebar-width: 216px;
|
||||||
|
--sidebar-width-collapsed: 56px;
|
||||||
|
|
||||||
|
--transition-fast: 120ms ease;
|
||||||
|
--transition-med: 220ms ease;
|
||||||
|
}
|
||||||
112
ui/rpki-explorer/src/test/fixtures/dashboard.ts
vendored
112
ui/rpki-explorer/src/test/fixtures/dashboard.ts
vendored
@ -1,112 +0,0 @@
|
|||||||
export const overviewKpis = [
|
|
||||||
{ label: "VRPs", value: "948,216", delta: "+12,834 (1.35%)", tone: "blue" },
|
|
||||||
{ label: "ASPAs", value: "4,812", delta: "+61 (2.95%)", tone: "cyan" },
|
|
||||||
{ label: "Repositories", value: "1,265", delta: "+8 (0.64%)", tone: "blue" },
|
|
||||||
{ label: "Publication Points", value: "92,438", delta: "+842 (1.63%)", tone: "purple" },
|
|
||||||
{ label: "Rejected Objects", value: "1,274", delta: "-31 (2.38%)", tone: "red" },
|
|
||||||
{ label: "Warnings", value: "3,219", delta: "+116 (3.75%)", tone: "amber" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const validationSlices = [
|
|
||||||
{ label: "Valid", value: 87, color: "#2563eb" },
|
|
||||||
{ label: "Warnings", value: 9, color: "#f59e0b" },
|
|
||||||
{ label: "Rejected", value: 4, color: "#ef4444" }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const objectTypeRows = [
|
|
||||||
{ type: "ROA", count: "741,092", percent: 64 },
|
|
||||||
{ type: "CER / RC", count: "118,437", percent: 18 },
|
|
||||||
{ type: "MFT", count: "54,221", percent: 10 },
|
|
||||||
{ type: "CRL", count: "31,804", percent: 6 },
|
|
||||||
{ type: "ASPA", count: "4,812", percent: 2 }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const topRepositories = [
|
|
||||||
{
|
|
||||||
host: "rpki.apnic.net",
|
|
||||||
transport: "RRDP",
|
|
||||||
duration: "12.8s",
|
|
||||||
objects: "228,192",
|
|
||||||
status: "healthy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
host: "rrdp.arin.net",
|
|
||||||
transport: "RRDP",
|
|
||||||
duration: "18.4s",
|
|
||||||
objects: "214,776",
|
|
||||||
status: "healthy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
host: "rpki.ripe.net",
|
|
||||||
transport: "RRDP",
|
|
||||||
duration: "22.1s",
|
|
||||||
objects: "308,427",
|
|
||||||
status: "warning"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
host: "rpki-repo.registro.br",
|
|
||||||
transport: "RSYNC",
|
|
||||||
duration: "9.7s",
|
|
||||||
objects: "19,031",
|
|
||||||
status: "fallback"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const validationIssues = [
|
|
||||||
{
|
|
||||||
severity: "high",
|
|
||||||
type: "MFT",
|
|
||||||
reason: "Manifest rejected by CRL validation",
|
|
||||||
uri: "rsync://rpki-repo.registro.br/repo/9mbS.../3kM7.mft",
|
|
||||||
repo: "rpki-repo.registro.br"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
severity: "medium",
|
|
||||||
type: "ROA",
|
|
||||||
reason: "EE certificate expired",
|
|
||||||
uri: "rsync://rpki.example.net/repository/AS64496.roa",
|
|
||||||
repo: "rpki.example.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
severity: "low",
|
|
||||||
type: "CRL",
|
|
||||||
reason: "Next update is close",
|
|
||||||
uri: "rsync://rpki.apnic.net/member_repository/example.crl",
|
|
||||||
repo: "rpki.apnic.net"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const objectDetail = {
|
|
||||||
id: "obj_7d4b_00042",
|
|
||||||
type: "ROA",
|
|
||||||
result: "valid",
|
|
||||||
uri: "rsync://rpki.apnic.net/member_repository/A91ED7F4/AS64500-203.0.113.0-24.roa",
|
|
||||||
sha256: "7d4b9a0c7f8832d5f6a9807f4371b61cf3131b6fe0fd1a89c9e4eeb7e33b8872",
|
|
||||||
source: "fresh validated",
|
|
||||||
repo: "rpki.apnic.net",
|
|
||||||
pp: "rsync://rpki.apnic.net/member_repository/A91ED7F4/",
|
|
||||||
parsed: {
|
|
||||||
asn: "AS64500",
|
|
||||||
prefixes: ["203.0.113.0/24 maxLength 24", "2001:db8:1200::/48 maxLength 48"],
|
|
||||||
eeSubject: "CN=AS64500 ROA EE",
|
|
||||||
validity: "2026-06-15T00:00:00Z → 2026-06-22T00:00:00Z"
|
|
||||||
},
|
|
||||||
chain: [
|
|
||||||
{ label: "APNIC TAL", state: "trust anchor" },
|
|
||||||
{ label: "APNIC Root CA", state: "valid CA" },
|
|
||||||
{ label: "Member CA A91ED7F4", state: "valid CA" },
|
|
||||||
{ label: "Manifest", state: "current" },
|
|
||||||
{ label: "ROA object", state: "valid" }
|
|
||||||
],
|
|
||||||
manifestFiles: [
|
|
||||||
{ name: "AS64500-203.0.113.0-24.roa", hash: "7d4b9a0c…8872", state: "matched" },
|
|
||||||
{ name: "A91ED7F4.crl", hash: "91ee1db2…af09", state: "current" },
|
|
||||||
{ name: "A91ED7F4.cer", hash: "3acaa8f0…21aa", state: "valid" }
|
|
||||||
],
|
|
||||||
validation: [
|
|
||||||
{ label: "CMS signature", status: "passed", note: "Signed attributes and EE chain are valid." },
|
|
||||||
{ label: "Resource containment", status: "passed", note: "ROA prefixes are covered by parent resources." },
|
|
||||||
{ label: "Manifest membership", status: "passed", note: "Object hash matches the current manifest entry." },
|
|
||||||
{ label: "Revocation", status: "passed", note: "EE certificate is not listed in current CRL." }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m7_copyable_values_pagination_playwright";
|
|
||||||
|
|
||||||
async function openRepositoryBranch(page: import("@playwright/test").Page) {
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
|
||||||
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
|
||||||
await reposSection.locator(".stack-row-select").first().click();
|
|
||||||
|
|
||||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
|
||||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await ppSection.locator(".stack-row-select").first().click();
|
|
||||||
|
|
||||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
|
||||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
|
||||||
return objectsPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("long repository, publication point, URI, and hash values expose copy and full hover text", async ({ context, page }) => {
|
|
||||||
await context.grantPermissions(["clipboard-write"], { origin: "http://127.0.0.1:5173" });
|
|
||||||
const objectsPanel = await openRepositoryBranch(page);
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Copy repository URI" }).first()).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy publication point URI" }).first()).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy manifest URI" }).first()).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const objectUri = objectsPanel.locator(".uri-cell .copyable-value-text").first();
|
|
||||||
const objectUriText = await objectUri.innerText();
|
|
||||||
await objectUri.hover();
|
|
||||||
await expect(page.locator(".copyable-tooltip")).toContainText(objectUriText);
|
|
||||||
|
|
||||||
await objectsPanel.getByRole("button", { name: "Copy object URI" }).first().click();
|
|
||||||
await expect(objectsPanel.locator(".copy-state-text").first()).toHaveText("Copied");
|
|
||||||
await objectUri.hover();
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/repository-copyable-tooltip.png`, fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("object detail keeps copy controls without extra object list panels", async ({ page }) => {
|
|
||||||
const objectsPanel = await openRepositoryBranch(page);
|
|
||||||
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
|
|
||||||
await expect(page.getByText("Object detail · live query service")).toBeVisible({ timeout: 70_000 });
|
|
||||||
await expect.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
|
||||||
|
|
||||||
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
|
|
||||||
|
|
||||||
const objectHash = page.locator(".object-meta-grid").getByText(/^[a-f0-9]{64}$/).first();
|
|
||||||
const objectHashText = await objectHash.innerText();
|
|
||||||
await objectHash.hover();
|
|
||||||
await expect(page.locator(".copyable-tooltip")).toContainText(objectHashText.toLowerCase());
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/object-detail-copyable-only.png`, fullPage: true });
|
|
||||||
});
|
|
||||||
321
ui/rpki-explorer/tests/e2e/fixtures.ts
Normal file
321
ui/rpki-explorer/tests/e2e/fixtures.ts
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic fixture dataset for the mocked query service.
|
||||||
|
* Object types use the report-stream vocabulary (manifest / certificate / aspa).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const RUN_2 = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
runId: "run_0002",
|
||||||
|
runSeq: 2,
|
||||||
|
validationTime: "2026-07-16T23:55:00Z",
|
||||||
|
syncMode: "delta",
|
||||||
|
wallMs: 732000,
|
||||||
|
artifactPaths: {
|
||||||
|
"report.json": "/data/runs/run_0002/report.json",
|
||||||
|
"run-summary.json": "/data/runs/run_0002/run-summary.json",
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
publicationPoints: 4,
|
||||||
|
objects: 1284,
|
||||||
|
rejectedObjects: 2,
|
||||||
|
warnings: 3,
|
||||||
|
trustAnchors: 5,
|
||||||
|
vrps: 987,
|
||||||
|
aspas: 12,
|
||||||
|
},
|
||||||
|
indexStatus: "ready",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUN_1 = {
|
||||||
|
...RUN_2,
|
||||||
|
runId: "run_0001",
|
||||||
|
runSeq: 1,
|
||||||
|
validationTime: "2026-07-15T23:50:00Z",
|
||||||
|
syncMode: "snapshot",
|
||||||
|
wallMs: 1290000,
|
||||||
|
artifactPaths: {
|
||||||
|
"report.json": "/data/runs/run_0001/report.json",
|
||||||
|
"run-summary.json": "/data/runs/run_0001/run-summary.json",
|
||||||
|
},
|
||||||
|
counts: { ...RUN_2.counts, objects: 1270, vrps: 981 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUNS = [RUN_2, RUN_1];
|
||||||
|
|
||||||
|
export const REPO_A = {
|
||||||
|
repoId: "repo-a1b2c3d4e5f6",
|
||||||
|
uri: "rsync://repo-a.example/ca1/",
|
||||||
|
host: "repo-a.example",
|
||||||
|
transport: "rrdp",
|
||||||
|
publicationPoints: 2,
|
||||||
|
objects: 900,
|
||||||
|
rejectedObjects: 1,
|
||||||
|
downloadBytes: 245_000_000,
|
||||||
|
syncDurationMsTotal: 12_400,
|
||||||
|
phases: { rrdp_snapshot: 1, rrdp_delta: 1 },
|
||||||
|
terminalStates: { fresh: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPO_B = {
|
||||||
|
repoId: "repo-b2c3d4e5f6a7",
|
||||||
|
uri: "rsync://repo-b.example/ca2/",
|
||||||
|
host: "repo-b.example",
|
||||||
|
transport: "rsync",
|
||||||
|
publicationPoints: 1,
|
||||||
|
objects: 300,
|
||||||
|
rejectedObjects: 1,
|
||||||
|
downloadBytes: 80_000_000,
|
||||||
|
syncDurationMsTotal: 45_800,
|
||||||
|
phases: { rsync: 1 },
|
||||||
|
terminalStates: { cached: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPO_C = {
|
||||||
|
repoId: "repo-c3d4e5f6a7b8",
|
||||||
|
uri: "rsync://repo-c.example/ca3/",
|
||||||
|
host: "repo-c.example",
|
||||||
|
transport: "rrdp",
|
||||||
|
publicationPoints: 1,
|
||||||
|
objects: 84,
|
||||||
|
rejectedObjects: 0,
|
||||||
|
downloadBytes: 9_000_000,
|
||||||
|
syncDurationMsTotal: 3_100,
|
||||||
|
phases: { rrdp_delta: 1 },
|
||||||
|
terminalStates: { fresh: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPOS = [REPO_A, REPO_B, REPO_C];
|
||||||
|
|
||||||
|
export const PP_1 = {
|
||||||
|
ppId: "node_1",
|
||||||
|
repoId: REPO_A.repoId,
|
||||||
|
nodeId: 1,
|
||||||
|
rsyncBaseUri: "rsync://repo-a.example/ca1/",
|
||||||
|
manifestRsyncUri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||||
|
publicationPointRsyncUri: "rsync://repo-a.example/ca1/",
|
||||||
|
rrdpNotificationUri: "https://repo-a.example/notification.xml",
|
||||||
|
source: "rrdp",
|
||||||
|
repoSyncSource: "rrdp",
|
||||||
|
repoSyncPhase: "rrdp_delta",
|
||||||
|
repoSyncDurationMs: 1200,
|
||||||
|
repoSyncError: null,
|
||||||
|
repoTerminalState: "fresh",
|
||||||
|
thisUpdate: "2026-07-16T23:00:00Z",
|
||||||
|
nextUpdate: "2026-07-17T23:00:00Z",
|
||||||
|
objects: 610,
|
||||||
|
rejectedObjects: 1,
|
||||||
|
warnings: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PP_2 = {
|
||||||
|
...PP_1,
|
||||||
|
ppId: "node_2",
|
||||||
|
nodeId: 2,
|
||||||
|
manifestRsyncUri: "rsync://repo-a.example/ca1b/manifest.mft",
|
||||||
|
publicationPointRsyncUri: "rsync://repo-a.example/ca1b/",
|
||||||
|
objects: 290,
|
||||||
|
rejectedObjects: 0,
|
||||||
|
warnings: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PP_3 = {
|
||||||
|
...PP_1,
|
||||||
|
ppId: "node_3",
|
||||||
|
nodeId: 3,
|
||||||
|
repoId: REPO_B.repoId,
|
||||||
|
manifestRsyncUri: "rsync://repo-b.example/ca2/manifest.mft",
|
||||||
|
publicationPointRsyncUri: "rsync://repo-b.example/ca2/",
|
||||||
|
rrdpNotificationUri: null,
|
||||||
|
source: "rsync",
|
||||||
|
repoSyncSource: "rsync",
|
||||||
|
repoSyncPhase: "rsync",
|
||||||
|
repoTerminalState: "cached",
|
||||||
|
objects: 300,
|
||||||
|
rejectedObjects: 1,
|
||||||
|
warnings: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PPS = [PP_1, PP_2, PP_3];
|
||||||
|
|
||||||
|
const SHA = (ch: string) => ch.repeat(64);
|
||||||
|
|
||||||
|
export const OBJ_ROA = {
|
||||||
|
objectInstanceId: "obj-roa-0001",
|
||||||
|
uri: "rsync://repo-a.example/ca1/alice.roa",
|
||||||
|
sha256: SHA("a"),
|
||||||
|
objectType: "roa",
|
||||||
|
result: "ok",
|
||||||
|
detailSummary: null,
|
||||||
|
repoId: REPO_A.repoId,
|
||||||
|
ppId: PP_1.ppId,
|
||||||
|
sourceSection: "fresh",
|
||||||
|
rejected: false,
|
||||||
|
rejectReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJ_MFT = {
|
||||||
|
objectInstanceId: "obj-mft-0001",
|
||||||
|
uri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||||
|
sha256: SHA("b"),
|
||||||
|
objectType: "manifest",
|
||||||
|
result: "ok",
|
||||||
|
detailSummary: null,
|
||||||
|
repoId: REPO_A.repoId,
|
||||||
|
ppId: PP_1.ppId,
|
||||||
|
sourceSection: "fresh",
|
||||||
|
rejected: false,
|
||||||
|
rejectReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJ_CER_REJECTED = {
|
||||||
|
objectInstanceId: "obj-cer-0001",
|
||||||
|
uri: "rsync://repo-a.example/ca1/stale.cer",
|
||||||
|
sha256: SHA("c"),
|
||||||
|
objectType: "certificate",
|
||||||
|
result: "error",
|
||||||
|
detailSummary: "certificate expired: notAfter 2026-01-01T00:00:00Z",
|
||||||
|
repoId: REPO_A.repoId,
|
||||||
|
ppId: PP_1.ppId,
|
||||||
|
sourceSection: "cached",
|
||||||
|
rejected: true,
|
||||||
|
rejectReason: "certificate expired: notAfter 2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJ_ASPA = {
|
||||||
|
objectInstanceId: "obj-asa-0001",
|
||||||
|
uri: "rsync://repo-b.example/ca2/bob.asa",
|
||||||
|
sha256: SHA("d"),
|
||||||
|
objectType: "aspa",
|
||||||
|
result: "ok",
|
||||||
|
detailSummary: null,
|
||||||
|
repoId: REPO_B.repoId,
|
||||||
|
ppId: PP_3.ppId,
|
||||||
|
sourceSection: "cached",
|
||||||
|
rejected: false,
|
||||||
|
rejectReason: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJ_ROA_REJECTED = {
|
||||||
|
objectInstanceId: "obj-roa-0002",
|
||||||
|
uri: "rsync://repo-b.example/ca2/broken.roa",
|
||||||
|
sha256: SHA("e"),
|
||||||
|
objectType: "roa",
|
||||||
|
result: "error",
|
||||||
|
detailSummary: "manifest stale: thisUpdate in the past",
|
||||||
|
repoId: REPO_B.repoId,
|
||||||
|
ppId: PP_3.ppId,
|
||||||
|
sourceSection: "cached",
|
||||||
|
rejected: true,
|
||||||
|
rejectReason: "manifest stale: thisUpdate in the past",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJECTS = [OBJ_ROA, OBJ_MFT, OBJ_CER_REJECTED, OBJ_ASPA, OBJ_ROA_REJECTED];
|
||||||
|
|
||||||
|
export const STATS_VALIDATION = { ok: 1279, error: 2, skipped: 3 };
|
||||||
|
export const STATS_OBJECT_TYPES = {
|
||||||
|
roa: 640,
|
||||||
|
manifest: 4,
|
||||||
|
crl: 4,
|
||||||
|
certificate: 620,
|
||||||
|
aspa: 12,
|
||||||
|
gbr: 4,
|
||||||
|
};
|
||||||
|
export const STATS_REASONS = {
|
||||||
|
"certificate expired: notAfter 2026-01-01T00:00:00Z": 1,
|
||||||
|
"manifest stale: thisUpdate in the past": 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROA_PROJECTION = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
sha256: SHA("a"),
|
||||||
|
objectType: "roa",
|
||||||
|
parseStatus: "ok",
|
||||||
|
projection: {
|
||||||
|
type: "roa",
|
||||||
|
roa: {
|
||||||
|
asId: 65001,
|
||||||
|
ipAddressFamilies: [
|
||||||
|
{
|
||||||
|
afi: "ipv4",
|
||||||
|
addresses: [
|
||||||
|
{ prefix: "203.0.113.0/24", maxLength: 24 },
|
||||||
|
{ prefix: "198.51.100.0/25", maxLength: 25 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
afi: "ipv6",
|
||||||
|
addresses: [{ prefix: "2001:db8::/32", maxLength: 48 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MFT_PROJECTION = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
sha256: SHA("b"),
|
||||||
|
objectType: "mft",
|
||||||
|
parseStatus: "ok",
|
||||||
|
projection: {
|
||||||
|
type: "mft",
|
||||||
|
manifest: {
|
||||||
|
manifestNumberHex: "0A2F",
|
||||||
|
thisUpdate: "2026-07-16T23:00:00Z",
|
||||||
|
nextUpdate: "2026-07-17T23:00:00Z",
|
||||||
|
fileHashAlg: "sha256",
|
||||||
|
fileCount: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MFT_FILES = [
|
||||||
|
{ fileName: "alice.roa", hashHex: SHA("a") },
|
||||||
|
{ fileName: "manifest.mft", hashHex: SHA("b") },
|
||||||
|
{ fileName: "stale.cer", hashHex: SHA("c") },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VALIDATION_SUMMARY = {
|
||||||
|
finalStatus: "valid",
|
||||||
|
auditResult: "ok",
|
||||||
|
detailSummary: null,
|
||||||
|
parsevalidate: { status: "ok", issues: [] },
|
||||||
|
chainvalidate: { status: "ok", issues: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXPLAIN_RECORD = {
|
||||||
|
finalStatus: "valid",
|
||||||
|
auditResult: "ok",
|
||||||
|
authoritative: false,
|
||||||
|
explainMode: "audit_projection",
|
||||||
|
parsevalidate: { status: "ok", issues: [] },
|
||||||
|
chainvalidate: { status: "ok", mode: "audit_projection", issues: [], edgesCount: 2 },
|
||||||
|
chainEdges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHAIN_EDGES = [
|
||||||
|
{
|
||||||
|
relation: "issued_by",
|
||||||
|
fromUri: "rsync://repo-a.example/ca1/alice.roa",
|
||||||
|
toUri: "rsync://repo-a.example/ca1/ca-certificate.cer",
|
||||||
|
toObjectInstanceId: "obj-cer-0002",
|
||||||
|
toSha256: SHA("f"),
|
||||||
|
status: "linked",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
relation: "listed_in",
|
||||||
|
fromUri: "rsync://repo-a.example/ca1/alice.roa",
|
||||||
|
toUri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||||
|
toObjectInstanceId: "obj-mft-0001",
|
||||||
|
toSha256: SHA("b"),
|
||||||
|
status: "linked",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HEALTH = {
|
||||||
|
status: "ok",
|
||||||
|
service: "rpki_query_service",
|
||||||
|
version: 1,
|
||||||
|
latestReadyRun: "run_0002",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SERVICE_INFO = { service: "rpki_query_service", version: 1 };
|
||||||
340
ui/rpki-explorer/tests/e2e/mockApi.ts
Normal file
340
ui/rpki-explorer/tests/e2e/mockApi.ts
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Mocked rpki_query_service for Playwright: intercepts every /api/v1 request
|
||||||
|
* and answers from the fixture dataset. Filter/query params are honored so
|
||||||
|
* tests can assert both request construction and rendered results.
|
||||||
|
*/
|
||||||
|
import type { Page, Route } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
CHAIN_EDGES,
|
||||||
|
EXPLAIN_RECORD,
|
||||||
|
HEALTH,
|
||||||
|
MFT_FILES,
|
||||||
|
MFT_PROJECTION,
|
||||||
|
OBJECTS,
|
||||||
|
PPS,
|
||||||
|
REPOS,
|
||||||
|
ROA_PROJECTION,
|
||||||
|
RUNS,
|
||||||
|
RUN_2,
|
||||||
|
SERVICE_INFO,
|
||||||
|
STATS_OBJECT_TYPES,
|
||||||
|
STATS_REASONS,
|
||||||
|
STATS_VALIDATION,
|
||||||
|
VALIDATION_SUMMARY,
|
||||||
|
} from "./fixtures";
|
||||||
|
|
||||||
|
export interface MockJob {
|
||||||
|
jobId: string;
|
||||||
|
runId: string;
|
||||||
|
scope: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
objectCount: number;
|
||||||
|
bytesWritten: number;
|
||||||
|
error: string | null;
|
||||||
|
polls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockApi {
|
||||||
|
jobs: Map<string, MockJob>;
|
||||||
|
seedJob: (partial?: Partial<MockJob>) => MockJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envelope(data: unknown, nextCursor: string | null = null, limit = 100) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
page: data === null || !Array.isArray(data) ? null : { nextCursor, limit },
|
||||||
|
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fulfillJson(route: Route, data: unknown, nextCursor: string | null = null, status = 200) {
|
||||||
|
return route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(data, nextCursor)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fulfillError(route: Route, status: number, message: string) {
|
||||||
|
return route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error: message }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset-cursor pagination honoring ?limit / ?cursor. */
|
||||||
|
function paginate<T>(items: T[], url: URL, defaultLimit = 50): { page: T[]; next: string | null } {
|
||||||
|
const limit = Math.max(1, Number(url.searchParams.get("limit") ?? defaultLimit));
|
||||||
|
const offset = Number(url.searchParams.get("cursor") ?? 0);
|
||||||
|
const page = items.slice(offset, offset + limit);
|
||||||
|
const nextOffset = offset + limit;
|
||||||
|
return { page, next: nextOffset < items.length ? String(nextOffset) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS {
|
||||||
|
let out = items;
|
||||||
|
const type = url.searchParams.get("type")?.toLowerCase();
|
||||||
|
const result = url.searchParams.get("result")?.toLowerCase();
|
||||||
|
const rejected = url.searchParams.get("rejected");
|
||||||
|
const reason = url.searchParams.get("reason")?.toLowerCase();
|
||||||
|
const q = url.searchParams.get("q")?.toLowerCase();
|
||||||
|
if (type) {
|
||||||
|
const alias: Record<string, string> = { mft: "manifest", cer: "certificate", asa: "aspa" };
|
||||||
|
const wanted = alias[type] ?? type;
|
||||||
|
out = out.filter((o) => o.objectType.toLowerCase() === wanted);
|
||||||
|
}
|
||||||
|
if (result) out = out.filter((o) => o.result.toLowerCase() === result);
|
||||||
|
if (rejected === "true") out = out.filter((o) => o.rejected);
|
||||||
|
if (rejected === "false") out = out.filter((o) => !o.rejected);
|
||||||
|
if (reason) out = out.filter((o) => o.rejectReason?.toLowerCase().includes(reason));
|
||||||
|
if (q) out = out.filter((o) => o.uri.toLowerCase().includes(q));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installMockApi(
|
||||||
|
page: Page,
|
||||||
|
options: { failLatestRun?: boolean } = {},
|
||||||
|
): Promise<MockApi> {
|
||||||
|
const jobs = new Map<string, MockJob>();
|
||||||
|
let jobSeq = 0;
|
||||||
|
|
||||||
|
/** Simulate async completion: a running job completes on its second read. */
|
||||||
|
const advanceJob = (job: MockJob) => {
|
||||||
|
job.polls += 1;
|
||||||
|
if (job.polls >= 2 && job.status === "running") {
|
||||||
|
job.status = "complete";
|
||||||
|
job.finishedAt = "2026-07-17T00:10:05Z";
|
||||||
|
job.objectCount = 1;
|
||||||
|
job.bytesWritten = 2048;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedJob = (partial: Partial<MockJob> = {}): MockJob => {
|
||||||
|
jobSeq += 1;
|
||||||
|
const job: MockJob = {
|
||||||
|
jobId: `job-${String(jobSeq).padStart(4, "0")}`,
|
||||||
|
runId: "run_0002",
|
||||||
|
scope: "object_set",
|
||||||
|
status: "running",
|
||||||
|
createdAt: "2026-07-17T00:10:00Z",
|
||||||
|
finishedAt: null,
|
||||||
|
objectCount: 0,
|
||||||
|
bytesWritten: 0,
|
||||||
|
error: null,
|
||||||
|
polls: 0,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
jobs.set(job.jobId, job);
|
||||||
|
return job;
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname.replace(/^\/api\/v1\/?/, "");
|
||||||
|
const segments = path.split("/").filter(Boolean).map(decodeURIComponent);
|
||||||
|
|
||||||
|
// Service-level
|
||||||
|
if (segments.length === 0) return fulfillJson(route, SERVICE_INFO);
|
||||||
|
if (path === "healthz") return fulfillJson(route, HEALTH);
|
||||||
|
|
||||||
|
if (segments[0] === "runs" || segments[0] === "latest_run") {
|
||||||
|
const isLatestAlias = segments[0] === "latest_run";
|
||||||
|
const rawRun = isLatestAlias ? "latest" : segments[1];
|
||||||
|
const rest = isLatestAlias ? [] : segments.slice(2);
|
||||||
|
|
||||||
|
if (!isLatestAlias && segments.length === 1) {
|
||||||
|
const { page: items, next } = paginate(RUNS, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = rawRun === "latest" || rawRun === "latest_run" ? RUN_2 : RUNS.find((r) => r.runId === rawRun);
|
||||||
|
if (!run) return fulfillError(route, 404, "run not found");
|
||||||
|
|
||||||
|
if (isLatestAlias || rest.length === 0) {
|
||||||
|
if (options.failLatestRun) return fulfillError(route, 500, "index unavailable");
|
||||||
|
return fulfillJson(route, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [section, a, b, c] = rest;
|
||||||
|
|
||||||
|
if (section === "artifacts") return fulfillJson(route, run.artifactPaths);
|
||||||
|
if (section === "summary") {
|
||||||
|
return fulfillJson(route, {
|
||||||
|
publicationPoints: run.counts.publicationPoints,
|
||||||
|
objects: run.counts.objects,
|
||||||
|
repos: REPOS.length,
|
||||||
|
vrps: run.counts.vrps,
|
||||||
|
aspas: run.counts.aspas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "repos" && !a) {
|
||||||
|
const { page: items, next } = paginate(REPOS, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
if (section === "repos" && a) {
|
||||||
|
const repo = REPOS.find((r) => r.repoId === a);
|
||||||
|
if (!repo) return fulfillError(route, 404, "repo not found");
|
||||||
|
if (!b) return fulfillJson(route, repo);
|
||||||
|
if (b === "stats") {
|
||||||
|
return fulfillJson(route, {
|
||||||
|
publicationPoints: repo.publicationPoints,
|
||||||
|
objects: repo.objects,
|
||||||
|
rejectedObjects: repo.rejectedObjects,
|
||||||
|
syncDurationMsTotal: repo.syncDurationMsTotal,
|
||||||
|
phases: repo.phases,
|
||||||
|
terminalStates: repo.terminalStates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (b === "publication-points") {
|
||||||
|
const { page: items, next } = paginate(PPS.filter((p) => p.repoId === a), url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
if (b === "objects") {
|
||||||
|
const filtered = applyObjectFilters(OBJECTS.filter((o) => o.repoId === a), url);
|
||||||
|
const { page: items, next } = paginate(filtered, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "publication-points" && !a) {
|
||||||
|
const { page: items, next } = paginate(PPS, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
if (section === "publication-points" && a) {
|
||||||
|
const pp = PPS.find((p) => p.ppId === a);
|
||||||
|
if (!pp) return fulfillError(route, 404, "publication point not found");
|
||||||
|
if (!b) return fulfillJson(route, pp);
|
||||||
|
if (b === "stats") {
|
||||||
|
return fulfillJson(route, {
|
||||||
|
objects: pp.objects,
|
||||||
|
rejectedObjects: pp.rejectedObjects,
|
||||||
|
warnings: pp.warnings,
|
||||||
|
repoSyncSource: pp.repoSyncSource,
|
||||||
|
repoSyncPhase: pp.repoSyncPhase,
|
||||||
|
repoSyncDurationMs: pp.repoSyncDurationMs,
|
||||||
|
repoTerminalState: pp.repoTerminalState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (b === "objects") {
|
||||||
|
const filtered = applyObjectFilters(OBJECTS.filter((o) => o.ppId === a), url);
|
||||||
|
const { page: items, next } = paginate(filtered, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "objects" && a === "by-uri") {
|
||||||
|
const uri = url.searchParams.get("uri");
|
||||||
|
const obj = OBJECTS.find((o) => o.uri === uri);
|
||||||
|
if (!obj) return fulfillError(route, 404, "object not found");
|
||||||
|
return fulfillJson(route, obj);
|
||||||
|
}
|
||||||
|
if (section === "objects" && !a) {
|
||||||
|
const filtered = applyObjectFilters(OBJECTS, url);
|
||||||
|
const { page: items, next } = paginate(filtered, url);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
if (section === "objects" && a) {
|
||||||
|
const obj = OBJECTS.find((o) => o.objectInstanceId === a);
|
||||||
|
if (!obj) return fulfillError(route, 404, "object not found");
|
||||||
|
if (!b) return fulfillJson(route, obj);
|
||||||
|
if (b === "parsed" && !c) {
|
||||||
|
if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION);
|
||||||
|
if (obj.objectType === "manifest") return fulfillJson(route, MFT_PROJECTION);
|
||||||
|
return fulfillJson(route, null);
|
||||||
|
}
|
||||||
|
if (b === "parsed" && c === "manifest-files") {
|
||||||
|
const { page: items, next } = paginate(MFT_FILES, url, 100);
|
||||||
|
return fulfillJson(route, items, next);
|
||||||
|
}
|
||||||
|
if (b === "parsed" && c === "revoked-certs") {
|
||||||
|
return fulfillJson(route, [], null);
|
||||||
|
}
|
||||||
|
if (b === "validation" && !c) return fulfillJson(route, VALIDATION_SUMMARY);
|
||||||
|
if (b === "validation" && c === "explain" && request.method() === "POST") {
|
||||||
|
return fulfillJson(route, EXPLAIN_RECORD);
|
||||||
|
}
|
||||||
|
if (b === "chain") return fulfillJson(route, CHAIN_EDGES);
|
||||||
|
if (b === "raw") {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: Buffer.from([0x30, 0x82, 0x01, 0x01, 0x02, 0x01, 0x05]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "issues") {
|
||||||
|
let items = OBJECTS.filter((o) => o.rejected || o.result === "error");
|
||||||
|
const reason = url.searchParams.get("reason")?.toLowerCase();
|
||||||
|
const type = url.searchParams.get("type")?.toLowerCase();
|
||||||
|
const repoId = url.searchParams.get("repoId");
|
||||||
|
if (reason) items = items.filter((o) => o.rejectReason?.toLowerCase().includes(reason));
|
||||||
|
if (type) items = items.filter((o) => o.objectType.toLowerCase() === type);
|
||||||
|
if (repoId) items = items.filter((o) => o.repoId === repoId);
|
||||||
|
const { page: pageItems, next } = paginate(items, url);
|
||||||
|
return fulfillJson(route, pageItems, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "search") {
|
||||||
|
const q = (url.searchParams.get("q") ?? "").trim();
|
||||||
|
if (!q) return fulfillError(route, 400, "q query parameter is required");
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
const byUri = OBJECTS.find((o) => o.uri === q);
|
||||||
|
const byHash = OBJECTS.find((o) => o.sha256 === lower);
|
||||||
|
const objects = byUri ? [byUri] : byHash ? [byHash] : [];
|
||||||
|
const repos = REPOS.filter(
|
||||||
|
(r) => r.host.toLowerCase().includes(lower) || r.uri.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
const pps = PPS.filter((p) =>
|
||||||
|
[p.manifestRsyncUri, p.publicationPointRsyncUri, p.rrdpNotificationUri]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((u) => u!.toLowerCase().includes(lower)),
|
||||||
|
);
|
||||||
|
return fulfillJson(route, { objects, repos, publicationPoints: pps });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "stats") {
|
||||||
|
if (a === "validation") return fulfillJson(route, STATS_VALIDATION);
|
||||||
|
if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES);
|
||||||
|
if (a === "reasons") return fulfillJson(route, STATS_REASONS);
|
||||||
|
if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 });
|
||||||
|
return fulfillJson(route, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "exports" && !a) {
|
||||||
|
if (request.method() === "POST") {
|
||||||
|
const job = seedJob({ objectCount: 0 });
|
||||||
|
return fulfillJson(route, job);
|
||||||
|
}
|
||||||
|
const list = [...jobs.values()].sort((x, y) => y.createdAt.localeCompare(x.createdAt));
|
||||||
|
list.forEach(advanceJob);
|
||||||
|
return fulfillJson(route, list, null);
|
||||||
|
}
|
||||||
|
if (section === "exports" && a) {
|
||||||
|
const job = jobs.get(a);
|
||||||
|
if (!job) return fulfillError(route, 404, "export job not found");
|
||||||
|
if (b === "download") {
|
||||||
|
if (job.status !== "complete") return fulfillError(route, 409, "export not complete");
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/x-tar",
|
||||||
|
body: Buffer.from("mock-tar-bytes"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
advanceJob(job);
|
||||||
|
return fulfillJson(route, job);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fulfillError(route, 404, `no mock for ${url.pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fulfillError(route, 404, `no mock for ${url.pathname}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobs, seedJob };
|
||||||
|
}
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
|
||||||
|
|
||||||
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
|
||||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
|
||||||
await reposSection.locator(".stack-row-select").first().click();
|
|
||||||
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
|
||||||
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
|
||||||
await expect(row).toBeVisible({ timeout: 70_000 });
|
|
||||||
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
|
||||||
await row.getByRole("button", { name: "Open" }).click();
|
|
||||||
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
|
||||||
return expectedUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("renders live object detail and lazy tab requests", async ({ page }) => {
|
|
||||||
const apiRequests: string[] = [];
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
|
|
||||||
page.on("request", (request) => {
|
|
||||||
const url = new URL(request.url());
|
|
||||||
if (url.pathname.startsWith("/api/v1")) {
|
|
||||||
apiRequests.push(`${url.pathname}${url.search}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.type() === "error") {
|
|
||||||
consoleErrors.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await openFirstRepositoryObject(page);
|
|
||||||
await expect(page.getByText("Object detail · live query service")).toBeVisible();
|
|
||||||
await expect(page.getByText("File and chain checks")).toBeVisible();
|
|
||||||
const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
|
|
||||||
await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
|
|
||||||
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false);
|
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Parsed" }).click();
|
|
||||||
await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 });
|
|
||||||
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Chain" }).click();
|
|
||||||
await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain");
|
|
||||||
expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Validation" }).click();
|
|
||||||
await page.getByRole("button", { name: "Explain validation" }).click();
|
|
||||||
await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 });
|
|
||||||
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
|
|
||||||
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true });
|
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
88
ui/rpki-explorer/tests/e2e/object-detail.spec.ts
Normal file
88
ui/rpki-explorer/tests/e2e/object-detail.spec.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object detail renders header metadata and parsed ROA projection", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-roa-0001");
|
||||||
|
|
||||||
|
await expect(page.getByText("ROA object")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Parsed tab is the default and renders structured ROA content
|
||||||
|
await expect(page.getByText("AS ID")).toBeVisible();
|
||||||
|
await expect(page.getByText("65001")).toBeVisible();
|
||||||
|
await expect(page.getByText("203.0.113.0/24")).toBeVisible();
|
||||||
|
await expect(page.getByText("2001:db8::/32")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tabs lazy-load: chain and validation requests fire only on activation", async ({ page }) => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("request", (req) => requests.push(req.url()));
|
||||||
|
|
||||||
|
await page.goto("/objects/obj-roa-0001");
|
||||||
|
await expect(page.getByText("65001")).toBeVisible();
|
||||||
|
expect(requests.some((u) => u.includes("/chain"))).toBe(false);
|
||||||
|
expect(requests.some((u) => u.includes("/validation"))).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Chain" }).click();
|
||||||
|
await expect.poll(() => requests.some((u) => u.includes("/chain"))).toBe(true);
|
||||||
|
await expect(page.getByText("issued_by")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Validation" }).click();
|
||||||
|
await expect.poll(() => requests.some((u) => u.includes("/validation"))).toBe(true);
|
||||||
|
await expect(page.getByText("Final status")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validation explain POSTs and renders the audit-projection notice", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-roa-0001?tab=validation");
|
||||||
|
const posts: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.method() === "POST" && req.url().includes("/validation/explain")) posts.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Run explain" }).click();
|
||||||
|
await expect.poll(() => posts.length).toBe(1);
|
||||||
|
await expect(page.getByText("audit_projection").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("not a full revalidation")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manifest object renders the file list", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-mft-0001");
|
||||||
|
await expect(page.getByText("Manifest number")).toBeVisible();
|
||||||
|
await expect(page.getByText("0A2F")).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "alice.roa" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object without projection shows the explicit unavailable state", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-asa-0001");
|
||||||
|
await expect(page.getByText("Parsed projection unavailable")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download raw triggers a browser download", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-roa-0001");
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: "Download raw" }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe("alice.roa");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export object set starts a job and offers the tarball when complete", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-roa-0001");
|
||||||
|
const posts: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.method() === "POST" && req.url().includes("/exports")) posts.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Export object set" }).click();
|
||||||
|
await expect.poll(() => posts.length).toBe(1);
|
||||||
|
// Mock completes the job after the first poll.
|
||||||
|
await expect(page.getByRole("link", { name: "Download tar" })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing object shows an error state", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-does-not-exist");
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Failed to load object");
|
||||||
|
});
|
||||||
55
ui/rpki-explorer/tests/e2e/objects.spec.ts
Normal file
55
ui/rpki-explorer/tests/e2e/objects.spec.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("objects page lists all objects with server-side filters", async ({ page }) => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.url().includes("/objects?")) requests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/objects");
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible();
|
||||||
|
|
||||||
|
// Type filter is sent to the backend and narrows the rendered list
|
||||||
|
await page.getByLabel("Type").selectOption("manifest");
|
||||||
|
await expect.poll(() => requests.some((url) => url.includes("type=manifest"))).toBe(true);
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeHidden();
|
||||||
|
|
||||||
|
// Rejected-only checkbox
|
||||||
|
await page.getByLabel("Type").selectOption("");
|
||||||
|
await page.getByRole("checkbox", { name: "Rejected only" }).click();
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked();
|
||||||
|
await expect.poll(() => requests.some((url) => url.includes("rejected=true"))).toBe(true);
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||||
|
|
||||||
|
// URI substring filter
|
||||||
|
await page.getByRole("checkbox", { name: "Rejected only" }).click();
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Rejected only" })).not.toBeChecked();
|
||||||
|
await page.getByLabel("URI contains").fill("repo-b");
|
||||||
|
await page.getByLabel("URI contains").press("Enter");
|
||||||
|
await expect.poll(() => requests.some((url) => url.includes("q=repo-b"))).toBe(true);
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("objects page deep link applies filters from the URL", async ({ page }) => {
|
||||||
|
await page.goto("/objects?type=certificate&rejected=true");
|
||||||
|
await expect(page.getByLabel("Type")).toHaveValue("certificate");
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("object row navigates to the object detail page", async ({ page }) => {
|
||||||
|
await page.goto("/objects");
|
||||||
|
await page.getByText("rsync://repo-a.example/ca1/alice.roa").click();
|
||||||
|
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
||||||
|
await expect(page.getByText("ROA object")).toBeVisible();
|
||||||
|
});
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
|
||||||
|
|
||||||
test("renders live overview data from query service without global object list", async ({ page }) => {
|
|
||||||
const apiRequests: string[] = [];
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
|
|
||||||
page.on("request", (request) => {
|
|
||||||
const url = new URL(request.url());
|
|
||||||
if (url.pathname.startsWith("/api/v1")) {
|
|
||||||
apiRequests.push(`${url.pathname}${url.search}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.type() === "error") {
|
|
||||||
consoleErrors.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
|
||||||
await expect(page.getByText(/run_\d+/).first()).toBeVisible();
|
|
||||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
|
||||||
await expect(page.locator(".issue-card").first()).toBeVisible();
|
|
||||||
|
|
||||||
expect(apiRequests).toContain("/api/v1/latest_run");
|
|
||||||
expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true });
|
|
||||||
});
|
|
||||||
63
ui/rpki-explorer/tests/e2e/overview.spec.ts
Normal file
63
ui/rpki-explorer/tests/e2e/overview.spec.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
|
||||||
|
test("overview renders run strip, KPIs, charts, top repos and reasons", async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Run strip
|
||||||
|
await expect(page.locator(".overview-run-strip").getByText("run_0002")).toBeVisible();
|
||||||
|
await expect(page.getByText("delta sync")).toBeVisible();
|
||||||
|
|
||||||
|
// KPI cards with fixture values
|
||||||
|
await expect(page.getByText("VRPs")).toBeVisible();
|
||||||
|
await expect(page.getByText("987")).toBeVisible();
|
||||||
|
await expect(page.locator(".kpi-grid").getByText("Rejected")).toBeVisible();
|
||||||
|
|
||||||
|
// Charts render (recharts svg)
|
||||||
|
await expect(page.locator(".recharts-responsive-container").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Top repositories table
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||||
|
|
||||||
|
// Top reject reasons
|
||||||
|
await expect(page.getByText("certificate expired: notAfter")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overview does not request the global object list (first-screen discipline)", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
const objectListRequests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (/\/api\/v1\/runs\/[^/]+\/objects$/.test(url.pathname)) {
|
||||||
|
objectListRequests.push(req.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("987")).toBeVisible();
|
||||||
|
expect(objectListRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overview drill-down: repo row navigates to repository detail", async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("cell", { name: "repo-b.example" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/repositories\/repo-b2c3d4e5f6a7/);
|
||||||
|
await expect(page.getByRole("heading", { name: "repo-b.example" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overview drill-down: reason navigates to filtered validation page", async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("link", { name: /manifest stale/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/validation\?reason=manifest/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Validation" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overview shows an explicit error state when the run cannot be loaded", async ({ page }) => {
|
||||||
|
await installMockApi(page, { failLatestRun: true });
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Failed to load the latest run");
|
||||||
|
});
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
|
||||||
|
|
||||||
test("loads repository, publication point, and object data on demand", async ({ page }) => {
|
|
||||||
const apiRequests: string[] = [];
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
|
|
||||||
page.on("request", (request) => {
|
|
||||||
const url = new URL(request.url());
|
|
||||||
if (url.pathname.startsWith("/api/v1")) {
|
|
||||||
apiRequests.push(`${url.pathname}${url.search}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.type() === "error") {
|
|
||||||
consoleErrors.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible();
|
|
||||||
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
|
||||||
|
|
||||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
|
||||||
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
|
||||||
await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/);
|
|
||||||
await reposSection.getByRole("button", { name: "Collapse repositories column" }).click();
|
|
||||||
await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories");
|
|
||||||
await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible();
|
|
||||||
await reposSection.getByRole("button", { name: "Expand repositories column" }).click();
|
|
||||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
|
||||||
await expect(reposSection.locator(".stack-row")).toHaveCount(1);
|
|
||||||
await reposSection.locator(".stack-row-select").first().click();
|
|
||||||
|
|
||||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
|
||||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
|
||||||
await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
|
||||||
await ppSection.getByRole("button", { name: "Collapse publication points column" }).click();
|
|
||||||
await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible();
|
|
||||||
await ppSection.getByRole("button", { name: "Expand publication points column" }).click();
|
|
||||||
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true);
|
|
||||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
|
||||||
|
|
||||||
await ppSection.locator(".stack-row-select").first().click();
|
|
||||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
|
||||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
|
||||||
expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true);
|
|
||||||
await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible();
|
|
||||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1");
|
|
||||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps selected repository and publication point after opening object detail", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
|
|
||||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
|
||||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
|
||||||
await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 });
|
|
||||||
const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
|
||||||
await reposSection.locator(".stack-row-select").first().click();
|
|
||||||
|
|
||||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
|
||||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
|
||||||
const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
|
||||||
await ppSection.locator(".stack-row-select").first().click();
|
|
||||||
|
|
||||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
|
||||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
|
||||||
const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim();
|
|
||||||
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
|
|
||||||
await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
|
||||||
await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya");
|
|
||||||
await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri);
|
|
||||||
await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri);
|
|
||||||
await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri);
|
|
||||||
});
|
|
||||||
78
ui/rpki-explorer/tests/e2e/repositories.spec.ts
Normal file
78
ui/rpki-explorer/tests/e2e/repositories.spec.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
import { REPOS } from "./fixtures";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repository list renders rows and filters the current page", async ({ page }) => {
|
||||||
|
await page.goto("/repositories");
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Filter current page").fill("repo-b");
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeHidden();
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repository list paginates with cursors", async ({ page }) => {
|
||||||
|
// Register a 2-page dataset on top of the base mock (later routes win).
|
||||||
|
await page.route("**/api/v1/runs/*/repos?**", (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const cursor = url.searchParams.get("cursor");
|
||||||
|
const items = cursor === "2" ? REPOS.slice(2) : REPOS.slice(0, 2);
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: items,
|
||||||
|
page: { nextCursor: cursor === "2" ? null : "2", limit: 50 },
|
||||||
|
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/repositories");
|
||||||
|
await expect(page.getByText("Page 1")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
await expect(page.getByText("Page 2")).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Previous" }).click();
|
||||||
|
await expect(page.getByText("Page 1")).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repository detail: tabs switch between PPs and objects", async ({ page }) => {
|
||||||
|
await page.goto("/repositories/repo-a1b2c3d4e5f6");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "repo-a.example" })).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible();
|
||||||
|
|
||||||
|
// Objects tab is lazy: no repo-scoped objects request before activation
|
||||||
|
const objectRequests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.url().includes("/repos/repo-a1b2c3d4e5f6/objects")) objectRequests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Objects" }).click();
|
||||||
|
await expect(page.getByLabel("URI contains")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible();
|
||||||
|
expect(objectRequests.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Publication points" }).click();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1b/manifest.mft")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publication point detail shows metadata and its objects", async ({ page }) => {
|
||||||
|
await page.goto("/publication-points/node_3");
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/manifest.mft").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("terminal cached")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publication points list navigates to detail", async ({ page }) => {
|
||||||
|
await page.goto("/publication-points");
|
||||||
|
await page.getByText("rsync://repo-b.example/ca2/manifest.mft").click();
|
||||||
|
await expect(page).toHaveURL(/\/publication-points\/node_3/);
|
||||||
|
});
|
||||||
@ -1,83 +1,101 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
function collectConsoleErrors(page: import("@playwright/test").Page) {
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
page.on("console", (message) => {
|
|
||||||
if (message.type() === "error") {
|
|
||||||
const text = message.text();
|
|
||||||
if (!text.includes("Failed to load resource")) {
|
|
||||||
consoleErrors.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return consoleErrors;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("opens the RPKI Explorer overview", async ({ page }) => {
|
|
||||||
const consoleErrors = collectConsoleErrors(page);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Current run")).toContainText("latest indexed run");
|
|
||||||
await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled();
|
|
||||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
|
||||||
await expect(page.getByText("Recent validation issues")).toBeVisible();
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true });
|
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => {
|
test("shell renders navigation, brand and connection status", async ({ page }) => {
|
||||||
const consoleErrors = collectConsoleErrors(page);
|
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
await expect(page.getByText("RPKI Explorer")).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Collapse navigation" }).click();
|
const nav = page.getByRole("navigation", { name: "Primary" });
|
||||||
await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/);
|
for (const label of [
|
||||||
await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible();
|
"Overview",
|
||||||
await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute");
|
"Repositories",
|
||||||
await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute");
|
"Publication Points",
|
||||||
await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible();
|
"Objects",
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true });
|
"Validation",
|
||||||
|
"Exports",
|
||||||
await page.getByRole("button", { name: "Repositories" }).click();
|
"Runs",
|
||||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
"API",
|
||||||
|
]) {
|
||||||
await page.getByRole("button", { name: "Expand navigation" }).click();
|
await expect(nav.getByRole("link", { name: label })).toBeVisible();
|
||||||
await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/);
|
|
||||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens truthful placeholders for unfinished navigation", async ({ page }) => {
|
|
||||||
const consoleErrors = collectConsoleErrors(page);
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) {
|
|
||||||
await page.getByRole("button", { name }).click();
|
|
||||||
await expect(page.locator("#coming-soon-heading")).toHaveText(name);
|
|
||||||
await expect(page.getByText("Coming soon", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true });
|
await expect(page.getByText("connected")).toBeVisible();
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("objects page starts from explicit empty state", async ({ page }) => {
|
test("every nav entry navigates to a real page (no dead controls)", async ({ page }) => {
|
||||||
const consoleErrors = collectConsoleErrors(page);
|
await page.goto("/");
|
||||||
|
const nav = page.getByRole("navigation", { name: "Primary" });
|
||||||
|
|
||||||
|
const cases: { label: string; path: RegExp; heading: string | RegExp }[] = [
|
||||||
|
{ label: "Repositories", path: /\/repositories$/, heading: "Repositories" },
|
||||||
|
{ label: "Publication Points", path: /\/publication-points$/, heading: "Publication Points" },
|
||||||
|
{ label: "Objects", path: /\/objects$/, heading: "Objects" },
|
||||||
|
{ label: "Validation", path: /\/validation$/, heading: "Validation" },
|
||||||
|
{ label: "Exports", path: /\/exports$/, heading: "Exports" },
|
||||||
|
{ label: "Runs", path: /\/runs$/, heading: "Runs" },
|
||||||
|
{ label: "API", path: /\/api$/, heading: "API" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { label, path, heading } of cases) {
|
||||||
|
await nav.getByRole("link", { name: label, exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(path);
|
||||||
|
await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run selector pins the run via ?run= and pages use it", async ({ page }) => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.url().includes("/api/v1/runs/")) requests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: /Objects/i }).click();
|
const selector = page.getByLabel("Run");
|
||||||
|
await expect(selector).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible();
|
await selector.selectOption("run_0001");
|
||||||
await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible();
|
await expect(page).toHaveURL(/run=run_0001/);
|
||||||
await expect(page.locator("body")).not.toContainText("sample PP");
|
await expect.poll(() => requests.some((url) => url.includes("/runs/run_0001/"))).toBe(true);
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true });
|
});
|
||||||
expect(consoleErrors).toEqual([]);
|
|
||||||
|
test("topbar search routes to the search page with the query", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const input = page.getByRole("searchbox", { name: "Global search" });
|
||||||
|
await input.fill("repo-a.example");
|
||||||
|
await input.press("Enter");
|
||||||
|
await expect(page).toHaveURL(/\/search\?q=repo-a/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Search", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown routes render the 404 page", async ({ page }) => {
|
||||||
|
await page.goto("/definitely-not-a-page");
|
||||||
|
await expect(page.getByText("404")).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "Back to Overview" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deep links restore page state after reload", async ({ page }) => {
|
||||||
|
await page.goto("/objects?type=manifest");
|
||||||
|
await expect(page).toHaveURL(/type=manifest/);
|
||||||
|
await expect(page.getByLabel("Type")).toHaveValue("manifest");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel("Type")).toHaveValue("manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs page lists runs, pins a run and expands artifacts", async ({ page }) => {
|
||||||
|
await page.goto("/runs");
|
||||||
|
await expect(page.getByRole("cell", { name: "run_0002" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "run_0001" })).toBeVisible();
|
||||||
|
|
||||||
|
// Expand artifacts for run_0001
|
||||||
|
const row = page.getByRole("row", { name: /run_0001/ });
|
||||||
|
await row.getByRole("button", { name: "Artifacts" }).click();
|
||||||
|
await expect(page.getByText("/data/runs/run_0001/report.json")).toBeVisible();
|
||||||
|
|
||||||
|
// Pin run_0001 — navigates to overview with ?run=run_0001
|
||||||
|
await row.getByRole("button", { name: "Use run" }).click();
|
||||||
|
await expect(page).toHaveURL(/run=run_0001/);
|
||||||
});
|
});
|
||||||
|
|||||||
70
ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts
Normal file
70
ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { installMockApi } from "./mockApi";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockApi(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validation page: issues table, distributions and reason filter", async ({ page }) => {
|
||||||
|
const issueRequests: string[] = [];
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.url().includes("/issues")) issueRequests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/validation");
|
||||||
|
|
||||||
|
// Both rejected objects are listed
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||||
|
|
||||||
|
// Result distribution
|
||||||
|
await expect(page.getByText("Result distribution")).toBeVisible();
|
||||||
|
|
||||||
|
// Click a reject-reason row → URL param + filtered request
|
||||||
|
await page.getByRole("button", { name: /manifest stale/ }).click();
|
||||||
|
await expect(page).toHaveURL(/reason=manifest/);
|
||||||
|
await expect.poll(() => issueRequests.some((u) => u.includes("reason=manifest"))).toBe(true);
|
||||||
|
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeHidden();
|
||||||
|
|
||||||
|
// Clear filters
|
||||||
|
await page.getByRole("link", { name: "Clear filters" }).click();
|
||||||
|
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: grouped results and navigation", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=repo-a.example");
|
||||||
|
await expect(page.getByText("Repositories (1)")).toBeVisible();
|
||||||
|
await expect(page.getByText("Publication points (2)")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /repo-a.example/ }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/repositories\/repo-a1b2c3d4e5f6|\/publication-points\/node_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: exact URI finds the object", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=rsync://repo-a.example/ca1/alice.roa");
|
||||||
|
await expect(page.getByText("Objects (1)")).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: /alice.roa/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: ASN/prefix queries show the #070 capability notice", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=AS65001");
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "feature #070" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: empty result state", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=nothing-matches-this");
|
||||||
|
await expect(page.getByText(/No results for/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exports page: seeded job completes and offers a download", async ({ page }) => {
|
||||||
|
const mock = await installMockApi(page);
|
||||||
|
mock.seedJob({ status: "running" });
|
||||||
|
|
||||||
|
await page.goto("/exports");
|
||||||
|
await expect(page.getByText("job-0001")).toBeVisible();
|
||||||
|
// Job transitions to complete after the poll interval
|
||||||
|
await expect(page.getByRole("link", { name: "Download" })).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByText("2.0 KiB")).toBeVisible();
|
||||||
|
});
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
|
||||||
|
|
||||||
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
|
||||||
await page.goto("/");
|
|
||||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
|
||||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
|
||||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
|
||||||
await reposSection.locator(".stack-row-select").first().click();
|
|
||||||
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
|
||||||
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
|
||||||
await expect(row).toBeVisible({ timeout: 70_000 });
|
|
||||||
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
|
||||||
await row.getByRole("button", { name: "Open" }).click();
|
|
||||||
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test("supports URI search and reports raw/export workflow status", async ({ page }) => {
|
|
||||||
const apiRequests: string[] = [];
|
|
||||||
page.on("request", (request) => {
|
|
||||||
const url = new URL(request.url());
|
|
||||||
if (url.pathname.startsWith("/api/v1")) {
|
|
||||||
apiRequests.push(`${request.method()} ${url.pathname}${url.search}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await openFirstRepositoryObject(page);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Use selected URI" }).click();
|
|
||||||
await page.getByPlaceholder("Exact URI lookup...").press("Enter");
|
|
||||||
await expect.poll(
|
|
||||||
() => apiRequests.some((request) => request.includes("/objects/by-uri")),
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Download raw" }).click();
|
|
||||||
await expect.poll(
|
|
||||||
() => apiRequests.some((request) => request.includes("/raw")),
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Export object" }).click();
|
|
||||||
await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 });
|
|
||||||
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Export selected PP" }).click();
|
|
||||||
await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true });
|
|
||||||
});
|
|
||||||
8
ui/rpki-explorer/vitest.config.ts
Normal file
8
ui/rpki-explorer/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user