20260728_2 修复rpki-explorer体验问题7项(#134):证书资源可展开、CMS对象新增EE Parsed Tab、Overview增加repo数KPI/修复Object types标签/Top repositories按objects排序(后端sort参数)/reject reasons按前缀聚类可下钻、Run选择器30s轮询
This commit is contained in:
parent
0d6bbec9f3
commit
f7f80fe684
@ -11,7 +11,7 @@ use rpki::query::report_stream::{ObjectFilter, ObjectScope};
|
|||||||
use rpki::query::vrp::VrpLookup;
|
use rpki::query::vrp::VrpLookup;
|
||||||
use rpki::query_db::{
|
use rpki::query_db::{
|
||||||
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb,
|
||||||
QueryDbError, ValidationExplainRecord,
|
QueryDbError, RepoSortOrder, ValidationExplainRecord,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@ -646,10 +646,11 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "repos"] => {
|
["runs", raw_run_id, "repos"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
page_response(
|
let page = match repo_sort_from_query(&query)? {
|
||||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||||
Some(run_id),
|
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||||
)
|
};
|
||||||
|
page_response(page, Some(run_id))
|
||||||
}
|
}
|
||||||
["runs", raw_run_id, "repos", repo_id] => {
|
["runs", raw_run_id, "repos", repo_id] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
@ -936,10 +937,11 @@ fn route_request(
|
|||||||
}
|
}
|
||||||
["runs", raw_run_id, "stats", "repos"] => {
|
["runs", raw_run_id, "stats", "repos"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
page_response(
|
let page = match repo_sort_from_query(&query)? {
|
||||||
db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?,
|
||||||
Some(run_id),
|
None => db.list_repos(&run_id, limit(&query), cursor(&query))?,
|
||||||
)
|
};
|
||||||
|
page_response(page, Some(run_id))
|
||||||
}
|
}
|
||||||
["runs", raw_run_id, "stats", "publication-points"] => {
|
["runs", raw_run_id, "stats", "publication-points"] => {
|
||||||
let run_id = resolve_run(db, raw_run_id)?;
|
let run_id = resolve_run(db, raw_run_id)?;
|
||||||
@ -2155,6 +2157,18 @@ fn exports_limit(query: &BTreeMap<String, String>) -> usize {
|
|||||||
.clamp(1, 200)
|
.clamp(1, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repo_sort_from_query(query: &BTreeMap<String, String>) -> Result<Option<RepoSortOrder>, ApiError> {
|
||||||
|
match query.get("sort").map(|value| value.as_str()) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some("objects") => match query.get("order").map(|value| value.as_str()) {
|
||||||
|
None | Some("desc") => Ok(Some(RepoSortOrder::ObjectsDesc)),
|
||||||
|
Some("asc") => Ok(Some(RepoSortOrder::ObjectsAsc)),
|
||||||
|
Some(other) => Err(ApiError::new(400, format!("invalid repos order: {other}"))),
|
||||||
|
},
|
||||||
|
Some(other) => Err(ApiError::new(400, format!("invalid repos sort: {other}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
|
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
|
||||||
let mut filter = ObjectFilter::default();
|
let mut filter = ObjectFilter::default();
|
||||||
if let Some(value) = query.get("type") {
|
if let Some(value) = query.get("type") {
|
||||||
|
|||||||
227
src/query_db.rs
227
src/query_db.rs
@ -162,6 +162,24 @@ pub struct RepositoryRecord {
|
|||||||
pub terminal_states: BTreeMap<String, u64>,
|
pub terminal_states: BTreeMap<String, u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort order for `QueryDb::list_repos_sorted`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RepoSortOrder {
|
||||||
|
ObjectsDesc,
|
||||||
|
ObjectsAsc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `repos:<offset>` cursor used by sorted repository listings.
|
||||||
|
fn parse_repos_offset_cursor(cursor: Option<&str>) -> QueryDbResult<usize> {
|
||||||
|
match cursor {
|
||||||
|
None => Ok(0),
|
||||||
|
Some(raw) => raw
|
||||||
|
.strip_prefix("repos:")
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.ok_or_else(|| QueryDbError::InvalidArtifact(format!("invalid repos cursor: {raw}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PublicationPointRecord {
|
pub struct PublicationPointRecord {
|
||||||
@ -410,6 +428,58 @@ impl QueryDb {
|
|||||||
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
|
self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List repositories sorted by object count. The sort happens at query
|
||||||
|
/// time over the run's repo set (hundreds of records at most), so no
|
||||||
|
/// extra index is needed. Pagination uses a `repos:<offset>` cursor,
|
||||||
|
/// independent from the index-order key cursor used by `list_repos`.
|
||||||
|
pub fn list_repos_sorted(
|
||||||
|
&self,
|
||||||
|
run_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
order: RepoSortOrder,
|
||||||
|
) -> QueryDbResult<QueryPage<RepositoryRecord>> {
|
||||||
|
let cf = self.cf(CF_REPOS)?;
|
||||||
|
let prefix = format!("repo/{run_id}/");
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for item in self.db.iterator_cf(
|
||||||
|
cf,
|
||||||
|
IteratorMode::From(prefix.as_bytes(), rocksdb::Direction::Forward),
|
||||||
|
) {
|
||||||
|
let (key, value) = item?;
|
||||||
|
if !key.starts_with(prefix.as_bytes()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
records.push(serde_json::from_slice::<RepositoryRecord>(&value)?);
|
||||||
|
}
|
||||||
|
records.sort_by(|left, right| {
|
||||||
|
let by_objects = match order {
|
||||||
|
RepoSortOrder::ObjectsDesc => right.objects.cmp(&left.objects),
|
||||||
|
RepoSortOrder::ObjectsAsc => left.objects.cmp(&right.objects),
|
||||||
|
};
|
||||||
|
// Stable, deterministic order for equal object counts.
|
||||||
|
by_objects.then_with(|| left.repo_id.cmp(&right.repo_id))
|
||||||
|
});
|
||||||
|
let offset = parse_repos_offset_cursor(cursor)?;
|
||||||
|
let bounded_limit = limit.clamp(1, 1000);
|
||||||
|
let total = records.len();
|
||||||
|
let data = records
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(bounded_limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let next_cursor = if offset + bounded_limit < total {
|
||||||
|
Some(format!("repos:{}", offset + bounded_limit))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(QueryPage {
|
||||||
|
data,
|
||||||
|
next_cursor,
|
||||||
|
limit: bounded_limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn lookup_vrps(
|
pub fn lookup_vrps(
|
||||||
&self,
|
&self,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
@ -2286,6 +2356,163 @@ mod tests {
|
|||||||
.expect("report");
|
.expect("report");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_db_list_repos_sorted_orders_by_object_count() {
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let run1 = temp.path().join("runs/run_0001");
|
||||||
|
fs::create_dir_all(&run1).expect("run1");
|
||||||
|
// Index order (a, b, c) deliberately differs from object-count order.
|
||||||
|
write_sample_run_with_repos(
|
||||||
|
&run1,
|
||||||
|
"run_0001",
|
||||||
|
1,
|
||||||
|
&[
|
||||||
|
("repo-a.example", 2),
|
||||||
|
("repo-b.example", 5),
|
||||||
|
("repo-c.example", 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let query_db_path = temp.path().join("query-db");
|
||||||
|
index_artifacts(&ArtifactIndexerConfig {
|
||||||
|
query_db_path: query_db_path.clone(),
|
||||||
|
run_root: Some(temp.path().to_path_buf()),
|
||||||
|
run_dir: None,
|
||||||
|
repo_bytes_db_path: None,
|
||||||
|
projection_entry_limit: 50,
|
||||||
|
min_run_seq: None,
|
||||||
|
retain_indexed_runs: None,
|
||||||
|
})
|
||||||
|
.expect("index");
|
||||||
|
|
||||||
|
let db = QueryDb::open(&query_db_path).expect("open query db");
|
||||||
|
let hosts = |page: QueryPage<RepositoryRecord>| {
|
||||||
|
page.data
|
||||||
|
.iter()
|
||||||
|
.map(|repo| (repo.host.clone(), repo.objects))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let desc = db
|
||||||
|
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsDesc)
|
||||||
|
.expect("desc");
|
||||||
|
assert_eq!(
|
||||||
|
hosts(desc),
|
||||||
|
vec![
|
||||||
|
("repo-b.example".to_string(), 5),
|
||||||
|
("repo-a.example".to_string(), 2),
|
||||||
|
("repo-c.example".to_string(), 1),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let asc = db
|
||||||
|
.list_repos_sorted("run_0001", 10, None, RepoSortOrder::ObjectsAsc)
|
||||||
|
.expect("asc");
|
||||||
|
assert_eq!(
|
||||||
|
hosts(asc),
|
||||||
|
vec![
|
||||||
|
("repo-c.example".to_string(), 1),
|
||||||
|
("repo-a.example".to_string(), 2),
|
||||||
|
("repo-b.example".to_string(), 5),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Offset cursor pagination over the sorted listing.
|
||||||
|
let first = db
|
||||||
|
.list_repos_sorted("run_0001", 2, None, RepoSortOrder::ObjectsDesc)
|
||||||
|
.expect("first page");
|
||||||
|
assert_eq!(first.data.len(), 2);
|
||||||
|
assert_eq!(first.data[0].host, "repo-b.example");
|
||||||
|
assert_eq!(first.next_cursor.as_deref(), Some("repos:2"));
|
||||||
|
let second = db
|
||||||
|
.list_repos_sorted(
|
||||||
|
"run_0001",
|
||||||
|
2,
|
||||||
|
first.next_cursor.as_deref(),
|
||||||
|
RepoSortOrder::ObjectsDesc,
|
||||||
|
)
|
||||||
|
.expect("second page");
|
||||||
|
assert_eq!(second.data.len(), 1);
|
||||||
|
assert_eq!(second.data[0].host, "repo-c.example");
|
||||||
|
assert!(second.next_cursor.is_none());
|
||||||
|
|
||||||
|
// A key-style cursor from the index-order listing is rejected.
|
||||||
|
assert!(db
|
||||||
|
.list_repos_sorted("run_0001", 2, Some("repo/run_0001/x"), RepoSortOrder::ObjectsDesc)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample run with one publication point per repo host, each holding
|
||||||
|
/// `object_count` ok objects, so repo records get distinct object counts.
|
||||||
|
fn write_sample_run_with_repos(
|
||||||
|
run_dir: &Path,
|
||||||
|
run_id: &str,
|
||||||
|
run_seq: u64,
|
||||||
|
repo_specs: &[(&str, usize)],
|
||||||
|
) {
|
||||||
|
let publication_points = repo_specs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (host, object_count))| {
|
||||||
|
let objects = (0..*object_count)
|
||||||
|
.map(|i| {
|
||||||
|
json!({
|
||||||
|
"rsync_uri": format!("rsync://{host}/rpki/obj{i}.roa"),
|
||||||
|
"sha256_hex": format!("{index:02x}{i:02x}"),
|
||||||
|
"kind": "roa",
|
||||||
|
"result": "ok"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
json!({
|
||||||
|
"node_id": 10 + index,
|
||||||
|
"rsync_base_uri": format!("rsync://{host}/rpki/"),
|
||||||
|
"manifest_rsync_uri": format!("rsync://{host}/rpki/m.mft"),
|
||||||
|
"publication_point_rsync_uri": format!("rsync://{host}/rpki/"),
|
||||||
|
"rrdp_notification_uri": format!("https://{host}/rrdp/notification.xml"),
|
||||||
|
"source": "rrdp",
|
||||||
|
"repo_sync_source": "rrdp",
|
||||||
|
"repo_sync_phase": "rrdp_snapshot",
|
||||||
|
"repo_sync_duration_ms": 100,
|
||||||
|
"repo_terminal_state": "fresh",
|
||||||
|
"warnings": [],
|
||||||
|
"objects": objects,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let report = json!({
|
||||||
|
"format_version": 2,
|
||||||
|
"meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"},
|
||||||
|
"tree": {"warnings": []},
|
||||||
|
"publication_points": publication_points,
|
||||||
|
"vrps": [],
|
||||||
|
"aspas": [],
|
||||||
|
"downloads": [],
|
||||||
|
"download_stats": {},
|
||||||
|
"repo_sync_stats": {}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
run_dir.join("report.json"),
|
||||||
|
serde_json::to_vec(&report).unwrap(),
|
||||||
|
)
|
||||||
|
.expect("report");
|
||||||
|
let summary = json!({
|
||||||
|
"status": "success",
|
||||||
|
"runId": run_id,
|
||||||
|
"runSeq": run_seq,
|
||||||
|
"startedAtRfc3339Utc": "2026-06-15T00:00:00Z",
|
||||||
|
"finishedAtRfc3339Utc": "2026-06-15T00:01:00Z",
|
||||||
|
"wallMs": 60000,
|
||||||
|
"reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": repo_specs.len(), "warnings": 0}
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
run_dir.join("run-summary.json"),
|
||||||
|
serde_json::to_vec(&summary).unwrap(),
|
||||||
|
)
|
||||||
|
.expect("summary");
|
||||||
|
fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage");
|
||||||
|
}
|
||||||
|
|
||||||
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
|
fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) {
|
||||||
let report = json!({
|
let report = json!({
|
||||||
"format_version": 2,
|
"format_version": 2,
|
||||||
|
|||||||
@ -117,9 +117,15 @@ export function getRunSummary(runId: string): Promise<RunSummary> {
|
|||||||
/* Repositories */
|
/* Repositories */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type RepoListParams = PageParams & {
|
||||||
|
/** Server-side sort; currently only "objects" is supported. */
|
||||||
|
sort?: "objects";
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
export function listRepos(
|
export function listRepos(
|
||||||
runId: string,
|
runId: string,
|
||||||
params: PageParams = {},
|
params: RepoListParams = {},
|
||||||
): Promise<ListResult<RepositoryRecord>> {
|
): Promise<ListResult<RepositoryRecord>> {
|
||||||
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params);
|
||||||
}
|
}
|
||||||
@ -418,6 +424,10 @@ export function getStatsObjectTypes(runId: string): Promise<Record<string, numbe
|
|||||||
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
return getData(`${runBase(runId)}/stats/object-types`, statsMapSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStatsOverview(runId: string): Promise<Record<string, number>> {
|
||||||
|
return getData(`${runBase(runId)}/stats/overview`, statsMapSchema);
|
||||||
|
}
|
||||||
|
|
||||||
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
export function getStatsValidation(runId: string): Promise<Record<string, number>> {
|
||||||
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
import { listManifestFiles, listRevokedCertificates } from "../api/service";
|
||||||
import type { ProjectionRecord } from "../api/schemas";
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
import { CopyableValue } from "./CopyableValue";
|
import { CopyableValue } from "./CopyableValue";
|
||||||
@ -132,6 +132,34 @@ function uriLines(uris: string[]): ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chip list that collapses to `collapsedCount` entries with a clickable
|
||||||
|
* "+N more" toggle; expanding shows every item (plus a "Show less" toggle).
|
||||||
|
*/
|
||||||
|
export function ExpandableChipList({ items, collapsedCount = 12 }: { items: string[]; collapsedCount?: number }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
if (!items.length) return <>—</>;
|
||||||
|
const collapsible = items.length > collapsedCount;
|
||||||
|
const visible = expanded || !collapsible ? items : items.slice(0, collapsedCount);
|
||||||
|
return (
|
||||||
|
<span className="chip-list">
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<span className="chip" key={i}>{item}</span>
|
||||||
|
))}
|
||||||
|
{collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip chip-more"
|
||||||
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? "Show less" : `+${items.length - collapsedCount} more`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@ -292,7 +320,7 @@ function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
export function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
// Real API shape: the certificate payload lives in `resourceCertificate`
|
// Real API shape: the certificate payload lives in `resourceCertificate`
|
||||||
// with extensions nested under `extensions`; keep the older
|
// with extensions nested under `extensions`; keep the older
|
||||||
// certificate/cer + flat-field variants working as fallbacks.
|
// certificate/cer + flat-field variants working as fallbacks.
|
||||||
@ -367,33 +395,11 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
|||||||
/>
|
/>
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="IP resources"
|
label="IP resources"
|
||||||
value={
|
value={<ExpandableChipList items={ipBlocks} />}
|
||||||
ipBlocks.length ? (
|
|
||||||
<span className="chip-list">
|
|
||||||
{ipBlocks.slice(0, 12).map((b, i) => (
|
|
||||||
<span className="chip" key={i}>{b}</span>
|
|
||||||
))}
|
|
||||||
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="AS resources"
|
label="AS resources"
|
||||||
value={
|
value={<ExpandableChipList items={asBlocks} />}
|
||||||
asBlocks.length ? (
|
|
||||||
<span className="chip-list">
|
|
||||||
{asBlocks.slice(0, 12).map((b, i) => (
|
|
||||||
<span className="chip" key={i}>{b}</span>
|
|
||||||
))}
|
|
||||||
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
||||||
</dl>
|
</dl>
|
||||||
@ -426,7 +432,6 @@ export function ProjectionView({
|
|||||||
|
|
||||||
const projection = record.projection;
|
const projection = record.projection;
|
||||||
const type = (record.objectType ?? "").toLowerCase();
|
const type = (record.objectType ?? "").toLowerCase();
|
||||||
|
|
||||||
// The query service nests the typed payload one level down:
|
// The query service nests the typed payload one level down:
|
||||||
// projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool }
|
// projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool }
|
||||||
// Accept a flat payload too, so older fixtures / shape variants keep working.
|
// Accept a flat payload too, so older fixtures / shape variants keep working.
|
||||||
|
|||||||
@ -59,6 +59,9 @@ function RunSelector() {
|
|||||||
queryKey: ["runs", "selector"],
|
queryKey: ["runs", "selector"],
|
||||||
queryFn: () => listRuns({ limit: 25 }),
|
queryFn: () => listRuns({ limit: 25 }),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
// Soak runs keep producing new runs; poll so the dropdown picks them up
|
||||||
|
// without a manual page refresh.
|
||||||
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
|
||||||
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
|
||||||
|
|||||||
@ -98,6 +98,10 @@ describe("objectTypeLabel", () => {
|
|||||||
expect(objectTypeLabel("mft")).toBe("Manifest");
|
expect(objectTypeLabel("mft")).toBe("Manifest");
|
||||||
expect(objectTypeLabel("asa")).toBe("ASPA");
|
expect(objectTypeLabel("asa")).toBe("ASPA");
|
||||||
});
|
});
|
||||||
|
it("maps router certificate spellings", () => {
|
||||||
|
expect(objectTypeLabel("router_certificate")).toBe("Router certificate");
|
||||||
|
expect(objectTypeLabel("router_cert")).toBe("Router certificate");
|
||||||
|
});
|
||||||
it("uppercases unknown types", () => {
|
it("uppercases unknown types", () => {
|
||||||
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
expect(objectTypeLabel("xyz")).toBe("XYZ");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -106,6 +106,9 @@ export function objectTypeLabel(type: string | null | undefined): string {
|
|||||||
return "Ghostbusters";
|
return "Ghostbusters";
|
||||||
case "ee":
|
case "ee":
|
||||||
return "EE Certificate";
|
return "EE Certificate";
|
||||||
|
case "router_certificate":
|
||||||
|
case "router_cert":
|
||||||
|
return "Router certificate";
|
||||||
case "other":
|
case "other":
|
||||||
return "Other";
|
return "Other";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { asArray, asNumber, asRecord, asString } from "./projection";
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
|
import { asArray, asNumber, asRecord, asString, eeCertificateProjection } from "./projection";
|
||||||
|
|
||||||
describe("projection guards", () => {
|
describe("projection guards", () => {
|
||||||
it("asRecord accepts plain objects only", () => {
|
it("asRecord accepts plain objects only", () => {
|
||||||
@ -23,3 +24,48 @@ describe("projection guards", () => {
|
|||||||
expect(asArray({})).toEqual([]);
|
expect(asArray({})).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("eeCertificateProjection", () => {
|
||||||
|
const eeCert = { kind: "Ee", subject: "CN=ee" };
|
||||||
|
const recordWith = (object: unknown): ProjectionRecord =>
|
||||||
|
({ parseStatus: "ok", projection: { object } }) as unknown as ProjectionRecord;
|
||||||
|
|
||||||
|
it("extracts the Ee certificate from the CMS wrapper", () => {
|
||||||
|
const record = recordWith({
|
||||||
|
type: "roa",
|
||||||
|
signedObject: {
|
||||||
|
signedData: {
|
||||||
|
certificates: [
|
||||||
|
{ resourceCertificate: { kind: "Ca", subject: "CN=ca" } },
|
||||||
|
{ resourceCertificate: eeCert },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(eeCertificateProjection(record)).toEqual({ resourceCertificate: eeCert });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first certificate when no Ee kind is marked", () => {
|
||||||
|
const record = recordWith({
|
||||||
|
signedObject: { signedData: { certificates: [{ resourceCertificate: eeCert }] } },
|
||||||
|
});
|
||||||
|
expect(eeCertificateProjection(record)).toEqual({ resourceCertificate: eeCert });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-CMS or malformed projections", () => {
|
||||||
|
expect(eeCertificateProjection(null)).toBeNull();
|
||||||
|
expect(eeCertificateProjection(recordWith({ type: "crl" }))).toBeNull();
|
||||||
|
expect(eeCertificateProjection(recordWith(null))).toBeNull();
|
||||||
|
expect(
|
||||||
|
eeCertificateProjection(recordWith({ signedObject: { signedData: { certificates: [] } } })),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the projection failed to parse", () => {
|
||||||
|
const record = {
|
||||||
|
parseStatus: "error",
|
||||||
|
projection: { object: { signedObject: { signedData: { certificates: [{ resourceCertificate: eeCert }] } } } },
|
||||||
|
} as unknown as ProjectionRecord;
|
||||||
|
expect(eeCertificateProjection(record)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
/** Defensive accessors for loosely-typed projection payloads. */
|
/** Defensive accessors for loosely-typed projection payloads. */
|
||||||
|
import type { ProjectionRecord } from "../api/schemas";
|
||||||
|
|
||||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
@ -16,3 +18,25 @@ export function asNumber(value: unknown): number | null {
|
|||||||
export function asArray(value: unknown): unknown[] {
|
export function asArray(value: unknown): unknown[] {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the CMS wrapper's EE certificate from a parsed projection, if any.
|
||||||
|
* Real shape: projection.object.signedObject.signedData.certificates[] — each
|
||||||
|
* entry carries a `resourceCertificate` with `kind: "Ee"` for the signer cert.
|
||||||
|
* Returns a `{ resourceCertificate }` projection for CertificateView, or null
|
||||||
|
* when the object is not CMS-signed or has no embedded certificate.
|
||||||
|
*/
|
||||||
|
export function eeCertificateProjection(
|
||||||
|
record: ProjectionRecord | null,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const projection = record?.projection;
|
||||||
|
if (!projection || record?.parseStatus === "error") return null;
|
||||||
|
const typed = asRecord(projection.object) ?? projection;
|
||||||
|
const certificates = asArray(asRecord(asRecord(typed.signedObject)?.signedData)?.certificates);
|
||||||
|
const certs = certificates
|
||||||
|
.map((entry) => asRecord(asRecord(entry)?.resourceCertificate))
|
||||||
|
.filter((cert): cert is Record<string, unknown> => Boolean(cert));
|
||||||
|
if (!certs.length) return null;
|
||||||
|
const ee = certs.find((cert) => asString(cert.kind) === "Ee") ?? certs[0];
|
||||||
|
return { resourceCertificate: ee };
|
||||||
|
}
|
||||||
|
|||||||
39
ui/rpki-explorer/src/lib/reasons.test.ts
Normal file
39
ui/rpki-explorer/src/lib/reasons.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clusterReasons, reasonClusterKey } from "./reasons";
|
||||||
|
|
||||||
|
describe("reasonClusterKey", () => {
|
||||||
|
it("takes the text before the first colon", () => {
|
||||||
|
expect(reasonClusterKey("manifest stale: thisUpdate in the past")).toBe("manifest stale");
|
||||||
|
expect(
|
||||||
|
reasonClusterKey("manifest is not valid at validation_time: this_update=2023-01-01"),
|
||||||
|
).toBe("manifest is not valid at validation_time");
|
||||||
|
});
|
||||||
|
it("keeps reasons without a colon intact", () => {
|
||||||
|
expect(reasonClusterKey("certificate expired")).toBe("certificate expired");
|
||||||
|
});
|
||||||
|
it("trims whitespace around the prefix", () => {
|
||||||
|
expect(reasonClusterKey("bad roa : detail")).toBe("bad roa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clusterReasons", () => {
|
||||||
|
it("merges reasons sharing a prefix and sums counts", () => {
|
||||||
|
const clusters = clusterReasons({
|
||||||
|
"manifest stale: thisUpdate in the past": 3,
|
||||||
|
"manifest stale: nextUpdate missing": 2,
|
||||||
|
"certificate expired: notAfter 2026-01-01": 5,
|
||||||
|
});
|
||||||
|
expect(clusters).toEqual([
|
||||||
|
{ key: "certificate expired", count: 5, variants: 1 },
|
||||||
|
{ key: "manifest stale", count: 5, variants: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("sorts by count desc, then key asc for determinism", () => {
|
||||||
|
const clusters = clusterReasons({ "b: 1": 4, "a: 2": 4, "c": 1 });
|
||||||
|
expect(clusters.map((c) => c.key)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
it("honours the limit", () => {
|
||||||
|
const clusters = clusterReasons({ "a: 1": 3, "b: 2": 2, "c: 3": 1 }, 2);
|
||||||
|
expect(clusters.map((c) => c.key)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
ui/rpki-explorer/src/lib/reasons.ts
Normal file
39
ui/rpki-explorer/src/lib/reasons.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Reject-reason clustering for the overview dashboard.
|
||||||
|
*
|
||||||
|
* Raw reject reasons embed object-specific details after a colon
|
||||||
|
* (e.g. "manifest is not valid at validation_time: this_update=2023-…"),
|
||||||
|
* which scatters one logical cause across many rows. Grouping by the text
|
||||||
|
* before the first colon collapses them back into a single cluster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cluster label for a raw reject reason: text before the first colon. */
|
||||||
|
export function reasonClusterKey(reason: string): string {
|
||||||
|
const idx = reason.indexOf(":");
|
||||||
|
return (idx === -1 ? reason : reason.slice(0, idx)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonCluster {
|
||||||
|
/** Cluster label (prefix before the first colon, or the whole reason). */
|
||||||
|
key: string;
|
||||||
|
/** Total rejected objects across all reasons in the cluster. */
|
||||||
|
count: number;
|
||||||
|
/** Distinct raw reasons merged into the cluster. */
|
||||||
|
variants: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate a reason→count map into clusters, sorted by count desc. */
|
||||||
|
export function clusterReasons(map: Record<string, number>, limit = 8): ReasonCluster[] {
|
||||||
|
const clusters = new Map<string, { count: number; variants: number }>();
|
||||||
|
for (const [reason, count] of Object.entries(map)) {
|
||||||
|
const key = reasonClusterKey(reason);
|
||||||
|
const entry = clusters.get(key) ?? { count: 0, variants: 0 };
|
||||||
|
entry.count += count;
|
||||||
|
entry.variants += 1;
|
||||||
|
clusters.set(key, entry);
|
||||||
|
}
|
||||||
|
return [...clusters.entries()]
|
||||||
|
.map(([key, { count, variants }]) => ({ key, count, variants }))
|
||||||
|
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
@ -16,12 +16,13 @@ import { CopyableValue } from "../components/CopyableValue";
|
|||||||
import { DataTable, type Column } from "../components/DataTable";
|
import { DataTable, type Column } from "../components/DataTable";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { ProjectionView } from "../components/ProjectionView";
|
import { CertificateView, ProjectionView } from "../components/ProjectionView";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||||
import { TabPanel, Tabs } from "../components/Tabs";
|
import { TabPanel, Tabs } from "../components/Tabs";
|
||||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||||
import { formatInt, objectTypeLabel } from "../lib/format";
|
import { formatInt, objectTypeLabel } from "../lib/format";
|
||||||
|
import { eeCertificateProjection } from "../lib/projection";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useExportJob } from "../lib/useExportJob";
|
import { useExportJob } from "../lib/useExportJob";
|
||||||
import { useRun } from "../lib/useRun";
|
import { useRun } from "../lib/useRun";
|
||||||
@ -212,10 +213,11 @@ export default function ObjectDetailPage() {
|
|||||||
queryFn: () => getObject(runId, objectInstanceId),
|
queryFn: () => getObject(runId, objectInstanceId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Always fetch the parsed projection: the Parsed tab needs it, and the
|
||||||
|
// conditional "EE Parsed" tab derives from it (CMS-signed objects only).
|
||||||
const projectionQuery = useQuery({
|
const projectionQuery = useQuery({
|
||||||
queryKey: ["object-parsed", runId, objectInstanceId],
|
queryKey: ["object-parsed", runId, objectInstanceId],
|
||||||
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
||||||
enabled: tab === "parsed",
|
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -251,6 +253,7 @@ export default function ObjectDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const object = objectQuery.data;
|
const object = objectQuery.data;
|
||||||
|
const eeProjection = eeCertificateProjection(projectionQuery.data ?? null);
|
||||||
|
|
||||||
const setTab = (next: string) => {
|
const setTab = (next: string) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
@ -351,6 +354,7 @@ export default function ObjectDetailPage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: "parsed", label: "Parsed" },
|
{ id: "parsed", label: "Parsed" },
|
||||||
|
...(eeProjection ? [{ id: "ee", label: "EE Parsed" }] : []),
|
||||||
{ id: "validation", label: "Validation" },
|
{ id: "validation", label: "Validation" },
|
||||||
{ id: "chain", label: "Chain" },
|
{ id: "chain", label: "Chain" },
|
||||||
]}
|
]}
|
||||||
@ -377,6 +381,17 @@ export default function ObjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
{eeProjection ? (
|
||||||
|
<TabPanel id="ee" active={tab}>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<CertificateView projection={eeProjection} />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
) : tab === "ee" ? (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Notice kind="info">This object has no embedded EE certificate in its CMS wrapper.</Notice>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<TabPanel id="validation" active={tab}>
|
<TabPanel id="validation" active={tab}>
|
||||||
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
import { Clock, GitBranch, Timer } from "lucide-react";
|
import { Clock, GitBranch, Timer } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getStatsObjectTypes,
|
getStatsObjectTypes,
|
||||||
|
getStatsOverview,
|
||||||
getStatsReasons,
|
getStatsReasons,
|
||||||
getStatsValidation,
|
getStatsValidation,
|
||||||
listRepos,
|
listRepos,
|
||||||
@ -35,6 +36,7 @@ import {
|
|||||||
objectTypeLabel,
|
objectTypeLabel,
|
||||||
truncateMiddle,
|
truncateMiddle,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
|
import { clusterReasons } from "../lib/reasons";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun, runErrorTitle } from "../lib/useRun";
|
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||||
|
|
||||||
@ -61,7 +63,7 @@ export default function OverviewPage() {
|
|||||||
const { runId, runQuery } = useRun();
|
const { runId, runQuery } = useRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({
|
const [validationQuery, typesQuery, reasonsQuery, reposQuery, overviewQuery] = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
{
|
{
|
||||||
queryKey: ["stats", runId, "validation"],
|
queryKey: ["stats", runId, "validation"],
|
||||||
@ -80,7 +82,12 @@ export default function OverviewPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
queryKey: ["repos", runId, "top"],
|
queryKey: ["repos", runId, "top"],
|
||||||
queryFn: () => listRepos(runId, { limit: 8 }),
|
queryFn: () => listRepos(runId, { limit: 8, sort: "objects", order: "desc" }),
|
||||||
|
staleTime: 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
queryKey: ["stats", runId, "overview"],
|
||||||
|
queryFn: () => getStatsOverview(runId),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -109,13 +116,10 @@ export default function OverviewPage() {
|
|||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [typesQuery.data]);
|
}, [typesQuery.data]);
|
||||||
|
|
||||||
const reasonRows = useMemo(() => {
|
const reasonRows = useMemo(
|
||||||
const map = reasonsQuery.data ?? {};
|
() => clusterReasons(reasonsQuery.data ?? {}),
|
||||||
return Object.entries(map)
|
[reasonsQuery.data],
|
||||||
.map(([reason, count]) => ({ reason, count }))
|
);
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 8);
|
|
||||||
}, [reasonsQuery.data]);
|
|
||||||
|
|
||||||
const maxReason = reasonRows[0]?.count ?? 0;
|
const maxReason = reasonRows[0]?.count ?? 0;
|
||||||
|
|
||||||
@ -224,6 +228,11 @@ export default function OverviewPage() {
|
|||||||
value={formatInt(counts?.publicationPoints)}
|
value={formatInt(counts?.publicationPoints)}
|
||||||
to={withRunParam("/publication-points", runId)}
|
to={withRunParam("/publication-points", runId)}
|
||||||
/>
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Repositories"
|
||||||
|
value={formatInt(overviewQuery.data?.repos)}
|
||||||
|
to={withRunParam("/repositories", runId)}
|
||||||
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Rejected"
|
label="Rejected"
|
||||||
value={formatInt(counts?.rejectedObjects)}
|
value={formatInt(counts?.rejectedObjects)}
|
||||||
@ -304,7 +313,7 @@ export default function OverviewPage() {
|
|||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
width={104}
|
width={150}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
||||||
@ -325,7 +334,7 @@ export default function OverviewPage() {
|
|||||||
<div className="overview-bottom">
|
<div className="overview-bottom">
|
||||||
<Panel
|
<Panel
|
||||||
title="Top repositories"
|
title="Top repositories"
|
||||||
subtitle="First repositories by index order"
|
subtitle="Repositories with the most objects"
|
||||||
tools={
|
tools={
|
||||||
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
||||||
View all
|
View all
|
||||||
@ -353,7 +362,7 @@ export default function OverviewPage() {
|
|||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
title="Top reject reasons"
|
title="Top reject reasons"
|
||||||
subtitle="Click a reason to inspect matching objects"
|
subtitle="Grouped by reason prefix — click to inspect matching objects"
|
||||||
tools={
|
tools={
|
||||||
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
||||||
Validation
|
Validation
|
||||||
@ -370,12 +379,17 @@ export default function OverviewPage() {
|
|||||||
<div className="reason-list">
|
<div className="reason-list">
|
||||||
{reasonRows.map((row) => (
|
{reasonRows.map((row) => (
|
||||||
<Link
|
<Link
|
||||||
key={row.reason}
|
key={row.key}
|
||||||
className="reason-row"
|
className="reason-row"
|
||||||
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
|
to={withRunParam(`/validation?reason=${encodeURIComponent(row.key)}`, runId)}
|
||||||
title={row.reason}
|
title={row.key}
|
||||||
>
|
>
|
||||||
<span className="reason-text">{row.reason}</span>
|
<span className="reason-text">
|
||||||
|
{row.key}
|
||||||
|
{row.variants > 1 ? (
|
||||||
|
<span className="text-faint text-small"> ×{row.variants}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span className="reason-count">{formatInt(row.count)}</span>
|
<span className="reason-count">{formatInt(row.count)}</span>
|
||||||
<span className="reason-bar" aria-hidden="true">
|
<span className="reason-bar" aria-hidden="true">
|
||||||
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
||||||
|
|||||||
@ -522,6 +522,18 @@ a.kpi-card:hover {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Clickable expand/collapse toggle inside an ExpandableChipList. */
|
||||||
|
button.chip-more {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--blue-700);
|
||||||
|
border-style: dashed;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.chip-more:hover {
|
||||||
|
background: var(--slate-100);
|
||||||
|
}
|
||||||
|
|
||||||
/* JSON block */
|
/* JSON block */
|
||||||
.json-block {
|
.json-block {
|
||||||
background: var(--text-1);
|
background: var(--text-1);
|
||||||
|
|||||||
@ -84,7 +84,9 @@ export const REPO_C = {
|
|||||||
terminalStates: { fresh: 1 },
|
terminalStates: { fresh: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const REPOS = [REPO_A, REPO_B, REPO_C];
|
// Deliberately not in object-count order: index order (300, 900, 84) differs
|
||||||
|
// from `sort=objects&order=desc` (900, 300, 84) so tests can tell them apart.
|
||||||
|
export const REPOS = [REPO_B, REPO_A, REPO_C];
|
||||||
|
|
||||||
export const PP_1 = {
|
export const PP_1 = {
|
||||||
ppId: "node_1",
|
ppId: "node_1",
|
||||||
@ -237,6 +239,15 @@ export const STATS_OBJECT_TYPES = {
|
|||||||
export const STATS_REASONS = {
|
export const STATS_REASONS = {
|
||||||
"certificate expired: notAfter 2026-01-01T00:00:00Z": 1,
|
"certificate expired: notAfter 2026-01-01T00:00:00Z": 1,
|
||||||
"manifest stale: thisUpdate in the past": 1,
|
"manifest stale: thisUpdate in the past": 1,
|
||||||
|
"manifest is not valid at validation_time: this_update=2023-01-01T00:00:00Z": 3,
|
||||||
|
"manifest is not valid at validation_time: this_update=2024-06-01T00:00:00Z": 2,
|
||||||
|
};
|
||||||
|
export const STATS_OVERVIEW = {
|
||||||
|
publicationPoints: 4,
|
||||||
|
objects: 1284,
|
||||||
|
repos: REPOS.length,
|
||||||
|
vrps: 987,
|
||||||
|
aspas: 12,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROA_PROJECTION = {
|
export const ROA_PROJECTION = {
|
||||||
@ -270,6 +281,43 @@ export const ROA_PROJECTION = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// CMS wrapper with the embedded EE certificate (real query service shape).
|
||||||
|
signedObject: {
|
||||||
|
signedData: {
|
||||||
|
certificates: [
|
||||||
|
{
|
||||||
|
resourceCertificate: {
|
||||||
|
kind: "Ee",
|
||||||
|
version: 3,
|
||||||
|
serialNumberHex: "0E65A501",
|
||||||
|
issuer: "CN=A9114E750000, serialNumber=5A179648",
|
||||||
|
subject: "CN=alice-roa-ee, serialNumber=0E65A501",
|
||||||
|
validity: { notBefore: "2026-07-23T00:30:06Z", notAfter: "2027-01-01T00:00:00Z" },
|
||||||
|
signatureAlgorithm: "1.2.840.113549.1.1.11",
|
||||||
|
extensions: {
|
||||||
|
subjectKeyIdentifier: "aa11bb22cc33dd44ee55ff66778899aabbccdd",
|
||||||
|
authorityKeyIdentifier: "5a179648b3ef2369dce7bdb58140ff7dc7060abf",
|
||||||
|
ipResources: {
|
||||||
|
families: [
|
||||||
|
{
|
||||||
|
afi: "Ipv4",
|
||||||
|
choice: {
|
||||||
|
AddressesOrRanges: [
|
||||||
|
{ Prefix: { addr: [203, 0, 113, 0], afi: "Ipv4", prefix_len: 24 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
asResources: {
|
||||||
|
asnum: { AsIdsOrRanges: [{ Id: 65001 }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
tool: "rpki",
|
tool: "rpki",
|
||||||
@ -373,7 +421,24 @@ export const CER_PROJECTION = {
|
|||||||
},
|
},
|
||||||
asResources: {
|
asResources: {
|
||||||
asnum: {
|
asnum: {
|
||||||
AsIdsOrRanges: [{ Id: 38008 }, { Id: 38023 }, { Range: { min: 65536, max: 65551 } }],
|
// 15 entries (> 12 collapsed limit) to exercise the "+N more" expand toggle.
|
||||||
|
AsIdsOrRanges: [
|
||||||
|
{ Id: 38008 },
|
||||||
|
{ Id: 38023 },
|
||||||
|
{ Range: { min: 65536, max: 65551 } },
|
||||||
|
{ Id: 64496 },
|
||||||
|
{ Id: 64497 },
|
||||||
|
{ Id: 64498 },
|
||||||
|
{ Id: 64499 },
|
||||||
|
{ Id: 64500 },
|
||||||
|
{ Id: 64501 },
|
||||||
|
{ Id: 64502 },
|
||||||
|
{ Id: 64503 },
|
||||||
|
{ Id: 64504 },
|
||||||
|
{ Id: 64505 },
|
||||||
|
{ Id: 64506 },
|
||||||
|
{ Id: 64507 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
RUN_2,
|
RUN_2,
|
||||||
SERVICE_INFO,
|
SERVICE_INFO,
|
||||||
STATS_OBJECT_TYPES,
|
STATS_OBJECT_TYPES,
|
||||||
|
STATS_OVERVIEW,
|
||||||
STATS_REASONS,
|
STATS_REASONS,
|
||||||
STATS_VALIDATION,
|
STATS_VALIDATION,
|
||||||
VALIDATION_SUMMARY,
|
VALIDATION_SUMMARY,
|
||||||
@ -234,8 +235,13 @@ export async function installMockApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (section === "repos" && !a) {
|
if (section === "repos" && !a) {
|
||||||
const { page: items, next } = paginate(REPOS, url);
|
let items = REPOS;
|
||||||
return fulfillJson(route, items, next);
|
if (url.searchParams.get("sort") === "objects") {
|
||||||
|
const dir = url.searchParams.get("order") === "asc" ? 1 : -1;
|
||||||
|
items = [...REPOS].sort((x, y) => dir * (x.objects - y.objects));
|
||||||
|
}
|
||||||
|
const { page: itemsPage, next } = paginate(items, url);
|
||||||
|
return fulfillJson(route, itemsPage, next);
|
||||||
}
|
}
|
||||||
if (section === "repos" && a) {
|
if (section === "repos" && a) {
|
||||||
const repo = REPOS.find((r) => r.repoId === a);
|
const repo = REPOS.find((r) => r.repoId === a);
|
||||||
@ -384,6 +390,7 @@ export async function installMockApi(
|
|||||||
if (a === "validation") return fulfillJson(route, STATS_VALIDATION);
|
if (a === "validation") return fulfillJson(route, STATS_VALIDATION);
|
||||||
if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES);
|
if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES);
|
||||||
if (a === "reasons") return fulfillJson(route, STATS_REASONS);
|
if (a === "reasons") return fulfillJson(route, STATS_REASONS);
|
||||||
|
if (a === "overview") return fulfillJson(route, STATS_OVERVIEW);
|
||||||
if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 });
|
if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 });
|
||||||
return fulfillJson(route, {});
|
return fulfillJson(route, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,8 +49,35 @@ test("validation explain POSTs and renders the audit-projection notice", async (
|
|||||||
await expect(page.getByText("not a full revalidation")).toBeVisible();
|
await expect(page.getByText("not a full revalidation")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("manifest object renders the file list", async ({ page }) => {
|
test("CMS-signed ROA shows the EE Parsed tab with the embedded certificate", async ({ page }) => {
|
||||||
await page.goto("/objects/obj-mft-0001");
|
await page.goto("/objects/obj-roa-0001");
|
||||||
|
|
||||||
|
const eeTab = page.getByRole("tab", { name: "EE Parsed" });
|
||||||
|
await expect(eeTab).toBeVisible();
|
||||||
|
await eeTab.click();
|
||||||
|
|
||||||
|
// EE certificate fields rendered like the Certificate object Parsed view.
|
||||||
|
await expect(page.getByText("Serial number")).toBeVisible();
|
||||||
|
await expect(page.getByText("0E65A501").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("CN=alice-roa-ee, serialNumber=0E65A501")).toBeVisible();
|
||||||
|
await expect(page.getByText("CN=A9114E750000, serialNumber=5A179648")).toBeVisible();
|
||||||
|
await expect(page.getByText("aa11bb22cc33dd44ee55ff66778899aabbccdd")).toBeVisible();
|
||||||
|
await expect(page.getByText("203.0.113.0/24")).toBeVisible();
|
||||||
|
await expect(page.getByText("AS65001", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("1.2.840.113549.1.1.11")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-CMS objects do not show the EE Parsed tab", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-crl-0001");
|
||||||
|
await expect(page.getByText("CRL number")).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "EE Parsed" })).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/objects/obj-cer-0001");
|
||||||
|
await expect(page.getByText("Serial number")).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: "EE Parsed" })).not.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("Manifest number")).toBeVisible();
|
||||||
await expect(page.getByText("0A2F")).toBeVisible();
|
await expect(page.getByText("0A2F")).toBeVisible();
|
||||||
await expect(page.getByRole("cell", { name: "alice.roa" })).toBeVisible();
|
await expect(page.getByRole("cell", { name: "alice.roa" })).toBeVisible();
|
||||||
|
|||||||
@ -13,15 +13,28 @@ test("overview renders run strip, KPIs, charts, top repos and reasons", async ({
|
|||||||
await expect(page.getByText("VRPs")).toBeVisible();
|
await expect(page.getByText("VRPs")).toBeVisible();
|
||||||
await expect(page.getByText("987")).toBeVisible();
|
await expect(page.getByText("987")).toBeVisible();
|
||||||
await expect(page.locator(".kpi-grid").getByText("Rejected")).toBeVisible();
|
await expect(page.locator(".kpi-grid").getByText("Rejected")).toBeVisible();
|
||||||
|
// Repositories KPI from stats/overview
|
||||||
|
const reposKpi = page.locator(".kpi-grid > *", { hasText: "Repositories" });
|
||||||
|
await expect(reposKpi).toBeVisible();
|
||||||
|
await expect(reposKpi).toContainText("3");
|
||||||
|
|
||||||
// Charts render (recharts svg)
|
// Charts render (recharts svg)
|
||||||
await expect(page.locator(".recharts-responsive-container").first()).toBeVisible();
|
await expect(page.locator(".recharts-responsive-container").first()).toBeVisible();
|
||||||
|
|
||||||
// Top repositories table
|
// Top repositories table, sorted by objects desc (900 > 300 > 84),
|
||||||
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
// not the fixture index order (300, 900, 84).
|
||||||
|
const repoRows = page.locator(".overview-bottom table tbody tr");
|
||||||
|
await expect(repoRows.first()).toContainText("repo-a.example");
|
||||||
|
await expect(repoRows.nth(1)).toContainText("repo-b.example");
|
||||||
|
await expect(repoRows.nth(2)).toContainText("repo-c.example");
|
||||||
|
|
||||||
// Top reject reasons
|
// Top reject reasons, clustered by prefix: the two validation_time
|
||||||
await expect(page.getByText("certificate expired: notAfter")).toBeVisible();
|
// variants merge into one row with a combined count.
|
||||||
|
const cluster = page.getByRole("link", { name: /manifest is not valid at validation_time/ });
|
||||||
|
await expect(cluster).toBeVisible();
|
||||||
|
await expect(cluster).toContainText("×2");
|
||||||
|
await expect(cluster).toContainText("5");
|
||||||
|
await expect(page.getByRole("link", { name: "certificate expired" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("overview does not request the global object list (first-screen discipline)", async ({
|
test("overview does not request the global object list (first-screen discipline)", async ({
|
||||||
@ -59,5 +72,5 @@ test("overview drill-down: reason navigates to filtered validation page", async
|
|||||||
test("overview shows an explicit error state when the run cannot be loaded", async ({ page }) => {
|
test("overview shows an explicit error state when the run cannot be loaded", async ({ page }) => {
|
||||||
await installMockApi(page, { failLatestRun: true });
|
await installMockApi(page, { failLatestRun: true });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("alert")).toContainText("Failed to load the latest run");
|
await expect(page.getByRole("alert")).toContainText("Failed to load run latest");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,6 +24,25 @@ test("certificate object renders the resourceCertificate projection", async ({ p
|
|||||||
await expect(page.getByText("1.2.840.113549.1.1.11")).toBeVisible();
|
await expect(page.getByText("1.2.840.113549.1.1.11")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("certificate AS resources collapse behind an expandable '+N more' toggle", async ({ page }) => {
|
||||||
|
await page.goto("/objects/obj-cer-0001");
|
||||||
|
|
||||||
|
// 15 AS entries: 12 visible, rest behind the toggle.
|
||||||
|
const moreToggle = page.getByRole("button", { name: "+3 more" });
|
||||||
|
await expect(moreToggle).toBeVisible();
|
||||||
|
await expect(page.getByText("AS38008", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("AS64507", { exact: true })).not.toBeVisible();
|
||||||
|
|
||||||
|
await moreToggle.click();
|
||||||
|
await expect(page.getByText("AS64507", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("AS64496", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const lessToggle = page.getByRole("button", { name: "Show less" });
|
||||||
|
await expect(lessToggle).toBeVisible();
|
||||||
|
await lessToggle.click();
|
||||||
|
await expect(page.getByText("AS64507", { exact: true })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("crl object renders header fields from the top-level payload", async ({ page }) => {
|
test("crl object renders header fields from the top-level payload", async ({ page }) => {
|
||||||
await page.goto("/objects/obj-crl-0001");
|
await page.goto("/objects/obj-crl-0001");
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,22 @@ test("run selector pins the run via ?run= and pages use it", async ({ page }) =>
|
|||||||
await expect.poll(() => requests.some((url) => url.includes("/runs/run_0001/"))).toBe(true);
|
await expect.poll(() => requests.some((url) => url.includes("/runs/run_0001/"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("run selector polls for new runs without a page refresh", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
let runsRequests = 0;
|
||||||
|
page.on("request", (req) => {
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (/\/api\/v1\/runs$/.test(url.pathname)) runsRequests += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByLabel("Run")).toBeVisible();
|
||||||
|
// Initial fetch happens immediately; the 30s refetchInterval issues another.
|
||||||
|
await expect
|
||||||
|
.poll(() => runsRequests, { timeout: 45_000, intervals: [1_000, 2_000, 5_000] })
|
||||||
|
.toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("topbar search routes to the search page with the query", async ({ page }) => {
|
test("topbar search routes to the search page with the query", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const input = page.getByRole("searchbox", { name: "Global search" });
|
const input = page.getByRole("searchbox", { name: "Global search" });
|
||||||
@ -87,11 +103,15 @@ test("deep links restore page state after reload", async ({ page }) => {
|
|||||||
|
|
||||||
test("runs page lists runs, pins a run and expands artifacts", async ({ page }) => {
|
test("runs page lists runs, pins a run and expands artifacts", async ({ page }) => {
|
||||||
await page.goto("/runs");
|
await page.goto("/runs");
|
||||||
await expect(page.getByRole("cell", { name: "run_0002" })).toBeVisible();
|
// Scope to the row: the actions cell also contains the run id in its
|
||||||
await expect(page.getByRole("cell", { name: "run_0001" })).toBeVisible();
|
// accessible name ("Use run run_0002 …"), so a bare cell lookup is ambiguous.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("row", { name: /run_0002/ }).getByRole("cell").first(),
|
||||||
|
).toContainText("run_0002");
|
||||||
|
const row = page.getByRole("row", { name: /run_0001/ });
|
||||||
|
await expect(row.getByRole("cell").first()).toContainText("run_0001");
|
||||||
|
|
||||||
// Expand artifacts for run_0001
|
// Expand artifacts for run_0001
|
||||||
const row = page.getByRole("row", { name: /run_0001/ });
|
|
||||||
await row.getByRole("button", { name: "Artifacts" }).click();
|
await row.getByRole("button", { name: "Artifacts" }).click();
|
||||||
await expect(page.getByText("/data/runs/run_0001/report.json")).toBeVisible();
|
await expect(page.getByText("/data/runs/run_0001/report.json")).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user