From f7f80fe684a943a3932236d5886c8774c45ec128 Mon Sep 17 00:00:00 2001 From: yuyr Date: Tue, 28 Jul 2026 13:21:58 +0800 Subject: [PATCH] =?UTF-8?q?20260728=5F2=20=E4=BF=AE=E5=A4=8Drpki-explorer?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E9=97=AE=E9=A2=987=E9=A1=B9(#134)=EF=BC=9A?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E8=B5=84=E6=BA=90=E5=8F=AF=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E3=80=81CMS=E5=AF=B9=E8=B1=A1=E6=96=B0=E5=A2=9EEE=20Parsed=20T?= =?UTF-8?q?ab=E3=80=81Overview=E5=A2=9E=E5=8A=A0repo=E6=95=B0KPI/=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DObject=20types=E6=A0=87=E7=AD=BE/Top=20repositories?= =?UTF-8?q?=E6=8C=89objects=E6=8E=92=E5=BA=8F(=E5=90=8E=E7=AB=AFsort?= =?UTF-8?q?=E5=8F=82=E6=95=B0)/reject=20reasons=E6=8C=89=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E8=81=9A=E7=B1=BB=E5=8F=AF=E4=B8=8B=E9=92=BB=E3=80=81Run?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A830s=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bin/rpki_query_service.rs | 32 ++- src/query_db.rs | 227 ++++++++++++++++++ ui/rpki-explorer/src/api/service.ts | 12 +- .../src/components/ProjectionView.tsx | 59 ++--- ui/rpki-explorer/src/components/Shell.tsx | 3 + ui/rpki-explorer/src/lib/format.test.ts | 4 + ui/rpki-explorer/src/lib/format.ts | 3 + ui/rpki-explorer/src/lib/projection.test.ts | 48 +++- ui/rpki-explorer/src/lib/projection.ts | 24 ++ ui/rpki-explorer/src/lib/reasons.test.ts | 39 +++ ui/rpki-explorer/src/lib/reasons.ts | 39 +++ .../src/pages/ObjectDetailPage.tsx | 19 +- ui/rpki-explorer/src/pages/OverviewPage.tsx | 46 ++-- ui/rpki-explorer/src/styles/components.css | 12 + ui/rpki-explorer/tests/e2e/fixtures.ts | 69 +++++- ui/rpki-explorer/tests/e2e/mockApi.ts | 11 +- .../tests/e2e/object-detail.spec.ts | 31 ++- ui/rpki-explorer/tests/e2e/overview.spec.ts | 23 +- .../tests/e2e/projection-cert-crl.spec.ts | 19 ++ ui/rpki-explorer/tests/e2e/shell.spec.ts | 26 +- 20 files changed, 676 insertions(+), 70 deletions(-) create mode 100644 ui/rpki-explorer/src/lib/reasons.test.ts create mode 100644 ui/rpki-explorer/src/lib/reasons.ts diff --git a/src/bin/rpki_query_service.rs b/src/bin/rpki_query_service.rs index 66f1b1c..e079428 100644 --- a/src/bin/rpki_query_service.rs +++ b/src/bin/rpki_query_service.rs @@ -11,7 +11,7 @@ use rpki::query::report_stream::{ObjectFilter, ObjectScope}; use rpki::query::vrp::VrpLookup; use rpki::query_db::{ ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb, - QueryDbError, ValidationExplainRecord, + QueryDbError, RepoSortOrder, ValidationExplainRecord, }; use serde::Serialize; use serde_json::{Value, json}; @@ -646,10 +646,11 @@ fn route_request( } ["runs", raw_run_id, "repos"] => { let run_id = resolve_run(db, raw_run_id)?; - page_response( - db.list_repos(&run_id, limit(&query), cursor(&query))?, - Some(run_id), - ) + let page = match repo_sort_from_query(&query)? { + Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?, + None => db.list_repos(&run_id, limit(&query), cursor(&query))?, + }; + page_response(page, Some(run_id)) } ["runs", raw_run_id, "repos", repo_id] => { let run_id = resolve_run(db, raw_run_id)?; @@ -936,10 +937,11 @@ fn route_request( } ["runs", raw_run_id, "stats", "repos"] => { let run_id = resolve_run(db, raw_run_id)?; - page_response( - db.list_repos(&run_id, limit(&query), cursor(&query))?, - Some(run_id), - ) + let page = match repo_sort_from_query(&query)? { + Some(order) => db.list_repos_sorted(&run_id, limit(&query), cursor(&query), order)?, + None => db.list_repos(&run_id, limit(&query), cursor(&query))?, + }; + page_response(page, Some(run_id)) } ["runs", raw_run_id, "stats", "publication-points"] => { let run_id = resolve_run(db, raw_run_id)?; @@ -2155,6 +2157,18 @@ fn exports_limit(query: &BTreeMap) -> usize { .clamp(1, 200) } +fn repo_sort_from_query(query: &BTreeMap) -> Result, 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) -> Result { let mut filter = ObjectFilter::default(); if let Some(value) = query.get("type") { diff --git a/src/query_db.rs b/src/query_db.rs index bb059bb..b6eba88 100644 --- a/src/query_db.rs +++ b/src/query_db.rs @@ -162,6 +162,24 @@ pub struct RepositoryRecord { pub terminal_states: BTreeMap, } +/// Sort order for `QueryDb::list_repos_sorted`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepoSortOrder { + ObjectsDesc, + ObjectsAsc, +} + +/// Parse a `repos:` cursor used by sorted repository listings. +fn parse_repos_offset_cursor(cursor: Option<&str>) -> QueryDbResult { + match cursor { + None => Ok(0), + Some(raw) => raw + .strip_prefix("repos:") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| QueryDbError::InvalidArtifact(format!("invalid repos cursor: {raw}"))), + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PublicationPointRecord { @@ -410,6 +428,58 @@ impl QueryDb { self.list_json_by_prefix(CF_REPOS, &format!("repo/{run_id}/"), limit, cursor) } + /// List repositories sorted by object count. The sort happens at query + /// time over the run's repo set (hundreds of records at most), so no + /// extra index is needed. Pagination uses a `repos:` 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> { + 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::(&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::>(); + let next_cursor = if offset + bounded_limit < total { + Some(format!("repos:{}", offset + bounded_limit)) + } else { + None + }; + Ok(QueryPage { + data, + next_cursor, + limit: bounded_limit, + }) + } + pub fn lookup_vrps( &self, run_id: &str, @@ -2286,6 +2356,163 @@ mod tests { .expect("report"); } + #[test] + fn query_db_list_repos_sorted_orders_by_object_count() { + let temp = tempfile::tempdir().expect("tempdir"); + let run1 = temp.path().join("runs/run_0001"); + fs::create_dir_all(&run1).expect("run1"); + // Index order (a, b, c) deliberately differs from object-count order. + write_sample_run_with_repos( + &run1, + "run_0001", + 1, + &[ + ("repo-a.example", 2), + ("repo-b.example", 5), + ("repo-c.example", 1), + ], + ); + + let query_db_path = temp.path().join("query-db"); + index_artifacts(&ArtifactIndexerConfig { + query_db_path: query_db_path.clone(), + run_root: Some(temp.path().to_path_buf()), + run_dir: None, + repo_bytes_db_path: None, + projection_entry_limit: 50, + min_run_seq: None, + retain_indexed_runs: None, + }) + .expect("index"); + + let db = QueryDb::open(&query_db_path).expect("open query db"); + let hosts = |page: QueryPage| { + page.data + .iter() + .map(|repo| (repo.host.clone(), repo.objects)) + .collect::>() + }; + + 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::>(); + 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::>(); + let report = json!({ + "format_version": 2, + "meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"}, + "tree": {"warnings": []}, + "publication_points": publication_points, + "vrps": [], + "aspas": [], + "downloads": [], + "download_stats": {}, + "repo_sync_stats": {} + }); + fs::write( + run_dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .expect("report"); + let summary = json!({ + "status": "success", + "runId": run_id, + "runSeq": run_seq, + "startedAtRfc3339Utc": "2026-06-15T00:00:00Z", + "finishedAtRfc3339Utc": "2026-06-15T00:01:00Z", + "wallMs": 60000, + "reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": repo_specs.len(), "warnings": 0} + }); + fs::write( + run_dir.join("run-summary.json"), + serde_json::to_vec(&summary).unwrap(), + ) + .expect("summary"); + fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage"); + } + fn write_sample_run(run_dir: &Path, run_id: &str, run_seq: u64) { let report = json!({ "format_version": 2, diff --git a/ui/rpki-explorer/src/api/service.ts b/ui/rpki-explorer/src/api/service.ts index 9ea5fd8..1d675c9 100644 --- a/ui/rpki-explorer/src/api/service.ts +++ b/ui/rpki-explorer/src/api/service.ts @@ -117,9 +117,15 @@ export function getRunSummary(runId: string): Promise { /* Repositories */ /* ------------------------------------------------------------------ */ +export type RepoListParams = PageParams & { + /** Server-side sort; currently only "objects" is supported. */ + sort?: "objects"; + order?: "asc" | "desc"; +}; + export function listRepos( runId: string, - params: PageParams = {}, + params: RepoListParams = {}, ): Promise> { return getList(`${runBase(runId)}/repos`, repositoryRecordSchema, params); } @@ -418,6 +424,10 @@ export function getStatsObjectTypes(runId: string): Promise> { + return getData(`${runBase(runId)}/stats/overview`, statsMapSchema); +} + export function getStatsValidation(runId: string): Promise> { return getData(`${runBase(runId)}/stats/validation`, statsMapSchema); } diff --git a/ui/rpki-explorer/src/components/ProjectionView.tsx b/ui/rpki-explorer/src/components/ProjectionView.tsx index 106b170..d03d976 100644 --- a/ui/rpki-explorer/src/components/ProjectionView.tsx +++ b/ui/rpki-explorer/src/components/ProjectionView.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { listManifestFiles, listRevokedCertificates } from "../api/service"; import type { ProjectionRecord } from "../api/schemas"; 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 ( + + {visible.map((item, i) => ( + {item} + ))} + {collapsible ? ( + + ) : null} + + ); +} + function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`); const query = useQuery({ @@ -292,7 +320,7 @@ function AspaView({ projection }: { projection: Record }) { ); } -function CertificateView({ projection }: { projection: Record }) { +export function CertificateView({ projection }: { projection: Record }) { // Real API shape: the certificate payload lives in `resourceCertificate` // with extensions nested under `extensions`; keep the older // certificate/cer + flat-field variants working as fallbacks. @@ -367,33 +395,11 @@ function CertificateView({ projection }: { projection: Record } /> - {ipBlocks.slice(0, 12).map((b, i) => ( - {b} - ))} - {ipBlocks.length > 12 ? +{ipBlocks.length - 12} more : null} - - ) : ( - "—" - ) - } + value={} /> - {asBlocks.slice(0, 12).map((b, i) => ( - {b} - ))} - {asBlocks.length > 12 ? +{asBlocks.length - 12} more : null} - - ) : ( - "—" - ) - } + value={} /> @@ -426,7 +432,6 @@ export function ProjectionView({ const projection = record.projection; const type = (record.objectType ?? "").toLowerCase(); - // The query service nests the typed payload one level down: // projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool } // Accept a flat payload too, so older fixtures / shape variants keep working. diff --git a/ui/rpki-explorer/src/components/Shell.tsx b/ui/rpki-explorer/src/components/Shell.tsx index 51ef8cd..da0bc7d 100644 --- a/ui/rpki-explorer/src/components/Shell.tsx +++ b/ui/rpki-explorer/src/components/Shell.tsx @@ -59,6 +59,9 @@ function RunSelector() { queryKey: ["runs", "selector"], queryFn: () => listRuns({ limit: 25 }), 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 latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId; diff --git a/ui/rpki-explorer/src/lib/format.test.ts b/ui/rpki-explorer/src/lib/format.test.ts index cd9a50c..5197f26 100644 --- a/ui/rpki-explorer/src/lib/format.test.ts +++ b/ui/rpki-explorer/src/lib/format.test.ts @@ -98,6 +98,10 @@ describe("objectTypeLabel", () => { expect(objectTypeLabel("mft")).toBe("Manifest"); 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", () => { expect(objectTypeLabel("xyz")).toBe("XYZ"); }); diff --git a/ui/rpki-explorer/src/lib/format.ts b/ui/rpki-explorer/src/lib/format.ts index ed80b44..7ebbb2d 100644 --- a/ui/rpki-explorer/src/lib/format.ts +++ b/ui/rpki-explorer/src/lib/format.ts @@ -106,6 +106,9 @@ export function objectTypeLabel(type: string | null | undefined): string { return "Ghostbusters"; case "ee": return "EE Certificate"; + case "router_certificate": + case "router_cert": + return "Router certificate"; case "other": return "Other"; default: diff --git a/ui/rpki-explorer/src/lib/projection.test.ts b/ui/rpki-explorer/src/lib/projection.test.ts index 145b191..3a34172 100644 --- a/ui/rpki-explorer/src/lib/projection.test.ts +++ b/ui/rpki-explorer/src/lib/projection.test.ts @@ -1,5 +1,6 @@ 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", () => { it("asRecord accepts plain objects only", () => { @@ -23,3 +24,48 @@ describe("projection guards", () => { 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(); + }); +}); diff --git a/ui/rpki-explorer/src/lib/projection.ts b/ui/rpki-explorer/src/lib/projection.ts index bc81a58..5be6c64 100644 --- a/ui/rpki-explorer/src/lib/projection.ts +++ b/ui/rpki-explorer/src/lib/projection.ts @@ -1,4 +1,6 @@ /** Defensive accessors for loosely-typed projection payloads. */ +import type { ProjectionRecord } from "../api/schemas"; + export function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -16,3 +18,25 @@ export function asNumber(value: unknown): number | null { export function asArray(value: unknown): unknown[] { 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 | 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 => Boolean(cert)); + if (!certs.length) return null; + const ee = certs.find((cert) => asString(cert.kind) === "Ee") ?? certs[0]; + return { resourceCertificate: ee }; +} diff --git a/ui/rpki-explorer/src/lib/reasons.test.ts b/ui/rpki-explorer/src/lib/reasons.test.ts new file mode 100644 index 0000000..18697d1 --- /dev/null +++ b/ui/rpki-explorer/src/lib/reasons.test.ts @@ -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"]); + }); +}); diff --git a/ui/rpki-explorer/src/lib/reasons.ts b/ui/rpki-explorer/src/lib/reasons.ts new file mode 100644 index 0000000..6afffb6 --- /dev/null +++ b/ui/rpki-explorer/src/lib/reasons.ts @@ -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, limit = 8): ReasonCluster[] { + const clusters = new Map(); + 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); +} diff --git a/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx index 6db169f..bbf974d 100644 --- a/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx +++ b/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx @@ -16,12 +16,13 @@ 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 { CertificateView, 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 { formatInt, objectTypeLabel } from "../lib/format"; +import { eeCertificateProjection } from "../lib/projection"; import { withRunParam } from "../lib/run"; import { useExportJob } from "../lib/useExportJob"; import { useRun } from "../lib/useRun"; @@ -212,10 +213,11 @@ export default function ObjectDetailPage() { 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({ queryKey: ["object-parsed", runId, objectInstanceId], queryFn: () => getObjectProjection(runId, objectInstanceId), - enabled: tab === "parsed", staleTime: 5 * 60_000, }); @@ -251,6 +253,7 @@ export default function ObjectDetailPage() { } const object = objectQuery.data; + const eeProjection = eeCertificateProjection(projectionQuery.data ?? null); const setTab = (next: string) => { const params = new URLSearchParams(searchParams); @@ -351,6 +354,7 @@ export default function ObjectDetailPage() { + {eeProjection ? ( + +
+ +
+
+ ) : tab === "ee" ? ( +
+ This object has no embedded EE certificate in its CMS wrapper. +
+ ) : null} diff --git a/ui/rpki-explorer/src/pages/OverviewPage.tsx b/ui/rpki-explorer/src/pages/OverviewPage.tsx index 31ec540..fad1543 100644 --- a/ui/rpki-explorer/src/pages/OverviewPage.tsx +++ b/ui/rpki-explorer/src/pages/OverviewPage.tsx @@ -15,6 +15,7 @@ import { import { Clock, GitBranch, Timer } from "lucide-react"; import { getStatsObjectTypes, + getStatsOverview, getStatsReasons, getStatsValidation, listRepos, @@ -35,6 +36,7 @@ import { objectTypeLabel, truncateMiddle, } from "../lib/format"; +import { clusterReasons } from "../lib/reasons"; import { withRunParam } from "../lib/run"; import { useRun, runErrorTitle } from "../lib/useRun"; @@ -61,7 +63,7 @@ export default function OverviewPage() { const { runId, runQuery } = useRun(); const navigate = useNavigate(); - const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({ + const [validationQuery, typesQuery, reasonsQuery, reposQuery, overviewQuery] = useQueries({ queries: [ { queryKey: ["stats", runId, "validation"], @@ -80,7 +82,12 @@ export default function OverviewPage() { }, { 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, }, ], @@ -109,13 +116,10 @@ export default function OverviewPage() { .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 reasonRows = useMemo( + () => clusterReasons(reasonsQuery.data ?? {}), + [reasonsQuery.data], + ); const maxReason = reasonRows[0]?.count ?? 0; @@ -224,6 +228,11 @@ export default function OverviewPage() { value={formatInt(counts?.publicationPoints)} to={withRunParam("/publication-points", runId)} /> + View all @@ -353,7 +362,7 @@ export default function OverviewPage() { Validation @@ -370,12 +379,17 @@ export default function OverviewPage() {
{reasonRows.map((row) => ( - {row.reason} + + {row.key} + {row.variants > 1 ? ( + ×{row.variants} + ) : null} + {formatInt(row.count)}