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:
@@ -117,9 +117,15 @@ export function getRunSummary(runId: string): Promise<RunSummary> {
|
||||
/* 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<ListResult<RepositoryRecord>> {
|
||||
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);
|
||||
}
|
||||
|
||||
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>> {
|
||||
return getData(`${runBase(runId)}/stats/validation`, statsMapSchema);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 }) {
|
||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||
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`
|
||||
// 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<string, unknown> }
|
||||
/>
|
||||
<MetaRow
|
||||
label="IP resources"
|
||||
value={
|
||||
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>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
value={<ExpandableChipList items={ipBlocks} />}
|
||||
/>
|
||||
<MetaRow
|
||||
label="AS resources"
|
||||
value={
|
||||
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>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
value={<ExpandableChipList items={asBlocks} />}
|
||||
/>
|
||||
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
|
||||
</dl>
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Defensive accessors for loosely-typed projection payloads. */
|
||||
import type { ProjectionRecord } from "../api/schemas";
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -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<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 { 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() {
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "parsed", label: "Parsed" },
|
||||
...(eeProjection ? [{ id: "ee", label: "EE Parsed" }] : []),
|
||||
{ id: "validation", label: "Validation" },
|
||||
{ id: "chain", label: "Chain" },
|
||||
]}
|
||||
@@ -377,6 +381,17 @@ export default function ObjectDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</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}>
|
||||
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
||||
</TabPanel>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Repositories"
|
||||
value={formatInt(overviewQuery.data?.repos)}
|
||||
to={withRunParam("/repositories", runId)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Rejected"
|
||||
value={formatInt(counts?.rejectedObjects)}
|
||||
@@ -304,7 +313,7 @@ export default function OverviewPage() {
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={104}
|
||||
width={150}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 12, fill: "var(--text-2)" }}
|
||||
@@ -325,7 +334,7 @@ export default function OverviewPage() {
|
||||
<div className="overview-bottom">
|
||||
<Panel
|
||||
title="Top repositories"
|
||||
subtitle="First repositories by index order"
|
||||
subtitle="Repositories with the most objects"
|
||||
tools={
|
||||
<Link className="btn small" to={withRunParam("/repositories", runId)}>
|
||||
View all
|
||||
@@ -353,7 +362,7 @@ export default function OverviewPage() {
|
||||
|
||||
<Panel
|
||||
title="Top reject reasons"
|
||||
subtitle="Click a reason to inspect matching objects"
|
||||
subtitle="Grouped by reason prefix — click to inspect matching objects"
|
||||
tools={
|
||||
<Link className="btn small" to={withRunParam("/validation", runId)}>
|
||||
Validation
|
||||
@@ -370,12 +379,17 @@ export default function OverviewPage() {
|
||||
<div className="reason-list">
|
||||
{reasonRows.map((row) => (
|
||||
<Link
|
||||
key={row.reason}
|
||||
key={row.key}
|
||||
className="reason-row"
|
||||
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
|
||||
title={row.reason}
|
||||
to={withRunParam(`/validation?reason=${encodeURIComponent(row.key)}`, runId)}
|
||||
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-bar" aria-hidden="true">
|
||||
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
|
||||
|
||||
@@ -522,6 +522,18 @@ a.kpi-card:hover {
|
||||
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 {
|
||||
background: var(--text-1);
|
||||
|
||||
Reference in New Issue
Block a user