20260718 重构 RPKI Explorer 查询界面

This commit is contained in:
yuyr 2026-07-18 10:38:38 +08:00
parent a77982deaa
commit a5902c0a5e
79 changed files with 7964 additions and 4861 deletions

View File

@ -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)
} }

View File

@ -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));

View File

@ -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,

View File

@ -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
``` ```

View File

@ -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
}
}
} }
} }
} }

View File

@ -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",

View File

@ -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"] },
} },
] ],
}); });

View 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");
});
});

View File

@ -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);
} }

View File

@ -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}`);
}

View 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;
}

View 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`;
}

View File

@ -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>
);
}

View File

@ -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>
);
} }

View File

@ -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,
} },
} },
}); });

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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);
}
}

View File

@ -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>
);
}

View File

@ -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)
};
}

View File

@ -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>
);
}

View File

@ -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("; ");
}

View File

@ -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());
}

View File

@ -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));
}

View File

@ -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: [] };
}

View 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);
});
});

View 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],
);
}

View 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("—");
});
});

View 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() : "—";
}
}

View 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([]);
});
});

View 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 : [];
}

View 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)}`;
}

View 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 };
}

View File

@ -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>,
); );

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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

View 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);
}

View 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);
}
}

View 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;
}

View File

@ -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." }
]
};

View File

@ -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 });
});

View 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 };

View 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 };
}

View File

@ -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([]);
});

View 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");
});

View 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();
});

View File

@ -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 });
});

View 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");
});

View File

@ -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);
});

View 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/);
});

View File

@ -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/);
}); });

View 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();
});

View File

@ -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 });
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});