20260723 新增 rpki-explorer 单容器部署资产并修复 parsed 投影渲染(#128):deploy/rpki-explorer 全套独立 compose 部署资产(对接 231 在跑 soak、retain 10 runs、SSH 转发访问);ProjectionView 按真实 API 结构解包 projection.object、证书走 resourceCertificate+extensions、CRL 读顶层字段并新增 IP/AS 资源格式化;e2e fixtures 对齐真实嵌套结构并新增证书/CRL parsed 覆盖
This commit is contained in:
@@ -19,6 +19,119 @@ function MetaRow({ label, value, mono = false }: { label: string; value: ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
/** Format an IP address from a projection byte array (v4 dotted / v6 compressed hextets). */
|
||||
function formatIpAddr(addr: unknown, afi: unknown): string | null {
|
||||
if (!Array.isArray(addr)) return null;
|
||||
const bytes = addr.filter((b): b is number => typeof b === "number");
|
||||
if (bytes.length === 0) return null;
|
||||
if (asString(afi) === "Ipv6" || bytes.length > 4) {
|
||||
const groups: number[] = [];
|
||||
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
||||
groups.push((bytes[i] << 8) | bytes[i + 1]);
|
||||
}
|
||||
// RFC 5952-style compression of the longest run of zero groups.
|
||||
let bestStart = -1;
|
||||
let bestLen = 0;
|
||||
for (let i = 0; i < groups.length; ) {
|
||||
if (groups[i] !== 0) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < groups.length && groups[j] === 0) j += 1;
|
||||
if (j - i > bestLen) {
|
||||
bestStart = i;
|
||||
bestLen = j - i;
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
const hex = groups.map((g) => g.toString(16));
|
||||
if (bestLen >= 2) {
|
||||
const head = hex.slice(0, bestStart).join(":");
|
||||
const tail = hex.slice(bestStart + bestLen).join(":");
|
||||
return `${head}::${tail}`;
|
||||
}
|
||||
return hex.join(":");
|
||||
}
|
||||
return bytes.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten projection ipResources to display strings.
|
||||
* Real shape (serde of the rpki types):
|
||||
* { families: [ { afi, choice: { AddressesOrRanges: [ {Prefix:{addr,afi,prefix_len}} | {Range:{min,max}} ] } } ] }
|
||||
*/
|
||||
function formatIpResources(resources: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
for (const famRaw of asArray(asRecord(resources)?.families)) {
|
||||
const fam = asRecord(famRaw);
|
||||
const choice = asRecord(fam?.choice);
|
||||
if (!choice) continue;
|
||||
if ("Inherit" in choice) {
|
||||
out.push("inherit");
|
||||
continue;
|
||||
}
|
||||
for (const entryRaw of asArray(choice.AddressesOrRanges)) {
|
||||
const entry = asRecord(entryRaw);
|
||||
const prefix = asRecord(entry?.Prefix);
|
||||
if (prefix) {
|
||||
const base = formatIpAddr(prefix.addr, prefix.afi ?? fam?.afi);
|
||||
out.push(base ? `${base}/${asNumber(prefix.prefix_len) ?? "?"}` : JSON.stringify(entryRaw));
|
||||
continue;
|
||||
}
|
||||
const range = asRecord(entry?.Range);
|
||||
if (range) {
|
||||
const min = formatIpAddr(asRecord(range.min)?.addr ?? range.min, fam?.afi);
|
||||
const max = formatIpAddr(asRecord(range.max)?.addr ?? range.max, fam?.afi);
|
||||
out.push(min && max ? `${min} - ${max}` : JSON.stringify(entryRaw));
|
||||
continue;
|
||||
}
|
||||
out.push(JSON.stringify(entryRaw));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten projection asResources to display strings.
|
||||
* Real shape: { asnum: { AsIdsOrRanges: [ {Id: n} | {Range: {min, max}} ] } } or { "inherit"/"Inherit": … }.
|
||||
*/
|
||||
function formatAsResources(resources: unknown): string[] {
|
||||
const rec = asRecord(resources);
|
||||
if (!rec) return [];
|
||||
if ("inherit" in rec || "Inherit" in rec) return ["inherit"];
|
||||
const out: string[] = [];
|
||||
for (const entryRaw of asArray(asRecord(rec.asnum)?.AsIdsOrRanges)) {
|
||||
const entry = asRecord(entryRaw);
|
||||
const id = asNumber(entry?.Id);
|
||||
if (id != null) {
|
||||
out.push(`AS${id}`);
|
||||
continue;
|
||||
}
|
||||
const range = asRecord(entry?.Range);
|
||||
if (range) {
|
||||
out.push(`AS${asNumber(range.min) ?? "?"}-AS${asNumber(range.max) ?? "?"}`);
|
||||
continue;
|
||||
}
|
||||
out.push(JSON.stringify(entryRaw));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a list of URIs (one per line) or null when empty. */
|
||||
function uriLines(uris: string[]): ReactNode {
|
||||
if (!uris.length) return null;
|
||||
return (
|
||||
<span>
|
||||
{uris.map((u) => (
|
||||
<span key={u} className="mono" style={{ display: "block", wordBreak: "break-all" }}>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||
const query = useQuery({
|
||||
@@ -176,11 +289,42 @@ function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
||||
}
|
||||
|
||||
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
|
||||
// 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.
|
||||
const cert =
|
||||
asRecord(projection.certificate) ??
|
||||
asRecord(projection.cer) ??
|
||||
asRecord(projection.resourceCertificate) ??
|
||||
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);
|
||||
const ext = asRecord(cert.extensions);
|
||||
const legacyResources = asRecord(cert.resources);
|
||||
|
||||
const siaDescriptions = asArray(asRecord(ext?.subjectInfoAccess)?.accessDescriptions);
|
||||
const siaUris = siaDescriptions
|
||||
.map((d) => asString(asRecord(d)?.accessLocation))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
const aiaUris = asArray(ext?.caIssuersUris)
|
||||
.map((u) => asString(u))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
const crlDpUris = asArray(ext?.crlDistributionPointsUris)
|
||||
.map((u) => asString(u))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
const extIp = formatIpResources(ext?.ipResources);
|
||||
const ipBlocks = extIp.length
|
||||
? extIp
|
||||
: asArray(legacyResources?.ipResources ?? legacyResources?.ipv4 ?? legacyResources?.addresses).map((b) =>
|
||||
typeof b === "string" ? b : JSON.stringify(b),
|
||||
);
|
||||
const extAs = formatAsResources(ext?.asResources);
|
||||
const asBlocks = extAs.length
|
||||
? extAs
|
||||
: asArray(legacyResources?.asResources ?? legacyResources?.asns).map((b) =>
|
||||
typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b),
|
||||
);
|
||||
|
||||
return (
|
||||
<dl className="meta-grid">
|
||||
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
||||
@@ -188,21 +332,42 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
||||
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
||||
<MetaRow
|
||||
label="Validity"
|
||||
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
|
||||
value={`${formatUtc(asString(validity?.notBefore)) ?? "—"} → ${formatUtc(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="SKI"
|
||||
value={<CopyableValue value={asString(cert.ski) ?? asString(ext?.subjectKeyIdentifier)} max={56} label="SKI" />}
|
||||
/>
|
||||
<MetaRow
|
||||
label="AKI"
|
||||
value={<CopyableValue value={asString(cert.aki) ?? asString(ext?.authorityKeyIdentifier)} max={56} label="AKI" />}
|
||||
/>
|
||||
<MetaRow
|
||||
label="SIA"
|
||||
value={asString(cert.sia) ? <CopyableValue value={asString(cert.sia)} max={72} label="SIA" /> : uriLines(siaUris)}
|
||||
/>
|
||||
<MetaRow
|
||||
label="AIA"
|
||||
value={asString(cert.aia) ? <CopyableValue value={asString(cert.aia)} max={72} label="AIA" /> : uriLines(aiaUris)}
|
||||
/>
|
||||
<MetaRow
|
||||
label="CRL DP"
|
||||
value={
|
||||
asString(cert.crlDistributionPoint ?? cert.crldp) ? (
|
||||
<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />
|
||||
) : (
|
||||
uriLines(crlDpUris)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<span className="chip" key={i}>{b}</span>
|
||||
))}
|
||||
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
||||
</span>
|
||||
@@ -217,7 +382,7 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
||||
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>
|
||||
<span className="chip" key={i}>{b}</span>
|
||||
))}
|
||||
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
||||
</span>
|
||||
@@ -258,11 +423,16 @@ export function ProjectionView({
|
||||
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} />;
|
||||
// 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.
|
||||
const typed = asRecord(projection.object) ?? projection;
|
||||
|
||||
if (type === "roa") return <RoaView projection={typed} />;
|
||||
if (type === "asa" || type === "aspa") return <AspaView projection={typed} />;
|
||||
|
||||
if (type === "mft") {
|
||||
const manifest = asRecord(projection.manifest);
|
||||
const manifest = asRecord(typed.manifest);
|
||||
return (
|
||||
<>
|
||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||
@@ -278,15 +448,37 @@ export function ProjectionView({
|
||||
}
|
||||
|
||||
if (type === "crl") {
|
||||
const crl = asRecord(projection.crl);
|
||||
// Real API shape: CRL fields sit directly on the typed payload
|
||||
// ({ issuer, thisUpdate, nextUpdate, extensions: { crlNumberHex, authorityKeyIdentifier }, … }),
|
||||
// there is no `crl` wrapper; keep `typed.crl` as a legacy fallback.
|
||||
const crl = asRecord(typed.crl) ?? typed;
|
||||
const crlExt = asRecord(crl.extensions);
|
||||
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" />} />
|
||||
<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 ?? crlExt?.crlNumberHex) ??
|
||||
(asNumber(crl.crlNumber ?? crlExt?.crlNumber) != null
|
||||
? String(asNumber(crl.crlNumber ?? crlExt?.crlNumber))
|
||||
: null)
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<MetaRow
|
||||
label="AKI"
|
||||
value={
|
||||
<CopyableValue
|
||||
value={asString(crl.aki) ?? asString(crlExt?.authorityKeyIdentifier)}
|
||||
max={56}
|
||||
label="AKI"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</>
|
||||
@@ -294,12 +486,12 @@ export function ProjectionView({
|
||||
}
|
||||
|
||||
if (type === "cer" || type === "ee" || type === "router_cert") {
|
||||
return <CertificateView projection={projection} />;
|
||||
return <CertificateView projection={typed} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="json-block" data-testid="projection-json">
|
||||
{JSON.stringify(projection, null, 2)}
|
||||
{JSON.stringify(typed, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user