From 0c88a21a399600af5a3d7980fbdbddf6e625f322 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 18 Apr 2026 02:23:13 +0530 Subject: [PATCH 1/4] feat(pki): PQC readiness pie + trend chart and inventory preset views --- backend/src/keystore/keystore.ts | 1 + .../src/server/routes/v1/project-router.ts | 45 ++++++ .../certificate-inventory-view-service.ts | 18 +++ .../services/certificate/certificate-dal.ts | 92 ++++++++++- .../src/services/project/project-service.ts | 38 +++++ .../src/hooks/api/certificates/constants.tsx | 19 +++ frontend/src/hooks/api/certificates/enums.tsx | 1 + frontend/src/hooks/api/certificates/index.tsx | 3 + .../src/hooks/api/certificates/queries.tsx | 22 ++- frontend/src/hooks/api/certificates/types.ts | 10 ++ .../components/CertificatesSection.tsx | 5 +- .../components/CertificatesTable.tsx | 60 ++++++- .../components/inventory-types.ts | 5 +- .../DashboardPage/DashboardPage.tsx | 25 ++- .../components/PqcReadinessChart.tsx | 147 ++++++++++++++++++ .../DashboardPage/components/PqcTrend.tsx | 129 +++++++++++++++ .../DashboardPage/components/chart-theme.tsx | 4 +- .../DashboardPage/components/index.tsx | 2 + .../PoliciesPage/PoliciesPage.tsx | 1 + .../CertificatesTab/CertificatesTab.tsx | 9 +- .../pages/cert-manager/PoliciesPage/route.tsx | 3 +- 21 files changed, 623 insertions(+), 16 deletions(-) create mode 100644 frontend/src/pages/cert-manager/DashboardPage/components/PqcReadinessChart.tsx create mode 100644 frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 9a1e96d1f65..788ab234d54 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -101,6 +101,7 @@ export const KeyStorePrefixes = { CertDashboardStats: (projectId: string) => `cert-dashboard-stats:${projectId}` as const, CertActivityTrend: (projectId: string, range: string) => `cert-activity-trend:${projectId}:${range}` as const, + CertPqcTrend: (projectId: string, range: string) => `cert-pqc-trend:${projectId}:${range}` as const, RefreshTokenGrace: (sessionId: string) => `refresh-token-grace:${sessionId}` as const, InsightsCache: (projectId: string, endpoint: string) => `insights-cache:${projectId}:${endpoint}` as const }; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index faea524e738..26f505579d9 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1462,6 +1462,51 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:projectId/certificates/pqc-trend", + config: { + rateLimit: readLimit + }, + schema: { + hide: true, + operationId: "getCertificatePqcTrend", + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate PQC adoption trend over time.", + params: z.object({ + projectId: z.string().trim() + }), + querystring: z.object({ + range: z.enum(["7d", "30d", "6m"]).optional().default("30d") + }), + response: { + 200: z.object({ + periods: z.array( + z.object({ + period: z.string(), + pqc: z.number(), + nonPqc: z.number() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.project.getPqcTrend({ + filter: { + projectId: req.params.projectId, + type: ProjectFilterType.ID + }, + range: req.query.range, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + } + }); + server.route({ method: "GET", url: "/:projectId/pki-alerts", diff --git a/backend/src/services/certificate-inventory-view/certificate-inventory-view-service.ts b/backend/src/services/certificate-inventory-view/certificate-inventory-view-service.ts index 2401bf406b4..a131ef93df3 100644 --- a/backend/src/services/certificate-inventory-view/certificate-inventory-view-service.ts +++ b/backend/src/services/certificate-inventory-view/certificate-inventory-view-service.ts @@ -6,6 +6,7 @@ import { TProjectPermission } from "@app/lib/types"; import { TPermissionServiceFactory } from "../../ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "../../ee/services/permission/project-permission"; +import { NON_PQC_KEY_ALGORITHMS, PQC_KEY_ALGORITHMS } from "../certificate/certificate-dal"; import { TCertificateInventoryViewDALFactory } from "./certificate-inventory-view-dal"; import { TCreateInventoryViewDTO, @@ -17,6 +18,7 @@ import { type TSystemViewFilters = { status?: string[]; notAfterTo?: string; + keyAlgorithm?: string[]; }; type TSystemView = { @@ -61,6 +63,22 @@ const SYSTEM_VIEWS: TSystemView[] = [ columns: null, isSystem: true, createdByUserId: null + }, + { + id: "system-pqc", + name: "Post-Quantum (PQC)", + filters: { keyAlgorithm: PQC_KEY_ALGORITHMS }, + columns: null, + isSystem: true, + createdByUserId: null + }, + { + id: "system-non-pqc", + name: "Classical (Non-PQC)", + filters: { keyAlgorithm: NON_PQC_KEY_ALGORITHMS }, + columns: null, + isSystem: true, + createdByUserId: null } ]; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 754d6346ed9..207ff832e1e 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -13,7 +13,23 @@ import { isUuidV4 } from "@app/lib/validator"; import { applyMetadataFilter } from "@app/services/resource-metadata/resource-metadata-fns"; import { keySizeToAlgorithms } from "./certificate-fns"; -import { CertStatus } from "./certificate-types"; +import { CertKeyAlgorithm, CertStatus } from "./certificate-types"; + +// Scoped to UI-surfaced algorithms; intentionally narrower than pqc-utils.PQC_ALGORITHMS. +export const PQC_KEY_ALGORITHMS: string[] = [ + CertKeyAlgorithm.ML_DSA_44, + CertKeyAlgorithm.ML_DSA_65, + CertKeyAlgorithm.ML_DSA_87 +]; + +export const NON_PQC_KEY_ALGORITHMS: string[] = [ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_3072, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384, + CertKeyAlgorithm.ECDSA_P521 +]; export type TCertificateDALFactory = ReturnType; @@ -918,6 +934,77 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const getPqcTrend = async (projectId: string, daysBack: number) => { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - daysBack); + startDate.setHours(0, 0, 0, 0); + const now = new Date(); + + const useDaily = daysBack <= 30; + if (!useDaily) { + startDate.setDate(1); + } + const truncUnit = useDaily ? "day" : "month"; + const dateFormat = useDaily ? "YYYY-MM-DD" : "YYYY-MM"; + + const rows = await db + .replicaNode()(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .where(`${TableName.CertificateAuthority}.projectId`, projectId) + .where(`${TableName.Certificate}.notBefore`, ">=", startDate) + .where(`${TableName.Certificate}.notBefore`, "<=", now) + .whereIn(`${TableName.Certificate}.keyAlgorithm`, [...PQC_KEY_ALGORITHMS, ...NON_PQC_KEY_ALGORITHMS]) + .select( + db.raw(`to_char(date_trunc(?, "${TableName.Certificate}"."notBefore"), ?) as period`, [truncUnit, dateFormat]) + ) + .select( + db.raw( + `CASE WHEN "${TableName.Certificate}"."keyAlgorithm" IN (${PQC_KEY_ALGORITHMS.map(() => "?").join(",")}) THEN 'pqc' ELSE 'nonPqc' END as bucket`, + [...PQC_KEY_ALGORITHMS] + ) + ) + .select(db.raw("count(*)::int as count")) + .groupBy("period", "bucket") + .orderBy("period"); + + interface PeriodBucketCount { + period: string; + bucket: "pqc" | "nonPqc"; + count: string; + } + const typed = rows as unknown as PeriodBucketCount[]; + const pqcMap = new Map(); + const nonPqcMap = new Map(); + typed.forEach((r) => { + if (r.bucket === "pqc") pqcMap.set(r.period, Number(r.count)); + else nonPqcMap.set(r.period, Number(r.count)); + }); + + const periods: Array<{ period: string; pqc: number; nonPqc: number }> = []; + const cursor = new Date(startDate); + while (cursor <= now) { + const periodKey = useDaily + ? `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}` + : `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`; + periods.push({ + period: periodKey, + pqc: pqcMap.get(periodKey) ?? 0, + nonPqc: nonPqcMap.get(periodKey) ?? 0 + }); + if (useDaily) { + cursor.setDate(cursor.getDate() + 1); + } else { + cursor.setMonth(cursor.getMonth() + 1); + } + } + + return { periods }; + } catch (error) { + throw new DatabaseError({ error, name: "Get PQC trend" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, @@ -932,6 +1019,7 @@ export const certificateDALFactory = (db: TDbClient) => { findWithPrivateKeyInfo, findWithFullDetails, getDashboardStats, - getActivityTrend + getActivityTrend, + getPqcTrend }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 80d15230695..d968ca60d1f 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -169,6 +169,7 @@ type TProjectServiceFactoryDep = { | "countActiveCertificatesForSync" | "getDashboardStats" | "getActivityTrend" + | "getPqcTrend" >; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; @@ -1403,6 +1404,42 @@ export const projectServiceFactory = ({ }); }; + const getPqcTrend = async ({ + filter, + range = "30d", + actorId, + actorOrgId, + actorAuthMethod, + actor + }: TGetActivityTrendDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + const rangeDaysMap: Record = { "7d": 7, "30d": 30, "6m": 180 }; + const daysBack = rangeDaysMap[range]; + + return withCache({ + keyStore, + key: KeyStorePrefixes.CertPqcTrend(projectId, range), + ttlSeconds: DASHBOARD_CACHE_TTL, + fetcher: () => certificateDAL.getPqcTrend(projectId, daysBack) + }); + }; + /** * Return list of (PKI) alerts configured for project */ @@ -2433,6 +2470,7 @@ export const projectServiceFactory = ({ listProjectCertificates, getDashboardStats, getActivityTrend, + getPqcTrend, listProjectAlerts, listProjectPkiCollections, listProjectCertificateTemplates, diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 049e5d900dd..325f455d3bd 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -28,6 +28,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_4096]: "RSA 4096", [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384", + [CertKeyAlgorithm.ECDSA_P521]: "ECDSA P521", [CertKeyAlgorithm.ML_DSA_44]: "ML-DSA-44", [CertKeyAlgorithm.ML_DSA_65]: "ML-DSA-65", [CertKeyAlgorithm.ML_DSA_87]: "ML-DSA-87" @@ -45,6 +46,24 @@ export const certSignatureAlgorithmToNameMap: Record = { "ML-DSA-87": "ML-DSA-87" }; +export const PQC_KEY_ALGORITHMS = [ + CertKeyAlgorithm.ML_DSA_44, + CertKeyAlgorithm.ML_DSA_65, + CertKeyAlgorithm.ML_DSA_87 +] as const; + +export const NON_PQC_KEY_ALGORITHMS = [ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_3072, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384, + CertKeyAlgorithm.ECDSA_P521 +] as const; + +export const isPqcAlgorithm = (label: string): boolean => + (PQC_KEY_ALGORITHMS as readonly string[]).includes(label); + export const certKeyAlgorithms = [ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 }, diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index d71cafb5fbc..0788fae3512 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -9,6 +9,7 @@ export enum CertKeyAlgorithm { RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1", + ECDSA_P521 = "EC_secp521r1", ML_DSA_44 = "ML-DSA-44", ML_DSA_65 = "ML-DSA-65", ML_DSA_87 = "ML-DSA-87" diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index 5f133e78643..cadf661efaf 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -17,6 +17,7 @@ export { useGetCertDashboardStats, useGetCertificateById, useGetCertificateRequest, + useGetCertPqcTrend, useListCertificateRequests } from "./queries"; export type { @@ -31,5 +32,7 @@ export type { TExpirationBucket, TListCertificateRequestsParams, TListCertificateRequestsResponse, + TPqcTrendPoint, + TPqcTrendResponse, TUpdateCertificateDTO } from "./types"; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 4401e1f978b..be6e2213ec1 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -9,7 +9,8 @@ import { TCertificateRequestDetails, TDashboardStats, TListCertificateRequestsParams, - TListCertificateRequestsResponse + TListCertificateRequestsResponse, + TPqcTrendResponse } from "./types"; export const certKeys = { @@ -38,7 +39,9 @@ export const certKeys = { ], getDashboardStats: (projectId: string) => ["cert-dashboard-stats", { projectId }] as const, getActivityTrend: (projectId: string, range: string) => - ["cert-activity-trend", { projectId }, { range }] as const + ["cert-activity-trend", { projectId }, { range }] as const, + getPqcTrend: (projectId: string, range: string) => + ["cert-pqc-trend", { projectId }, { range }] as const }; export const useGetCert = (serialNumber: string) => { @@ -175,3 +178,18 @@ export const useGetCertActivityTrend = (projectId: string, range = "6m") => { staleTime: DASHBOARD_STALE_TIME }); }; + +export const useGetCertPqcTrend = (projectId: string, range = "30d") => { + return useQuery({ + queryKey: certKeys.getPqcTrend(projectId, range), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/projects/${projectId}/certificates/pqc-trend`, + { params: { range } } + ); + return data; + }, + enabled: Boolean(projectId), + staleTime: DASHBOARD_STALE_TIME + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 58ef184eabf..3e762db0c76 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -283,3 +283,13 @@ export type TActivityTrendPoint = { export type TActivityTrendResponse = { periods: TActivityTrendPoint[]; }; + +export type TPqcTrendPoint = { + period: string; + pqc: number; + nonPqc: number; +}; + +export type TPqcTrendResponse = { + periods: TPqcTrendPoint[]; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 8f7b2e0d594..2677c1bc017 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -36,11 +36,13 @@ type CertificatesSectionProps = { search?: string; }; dashboardFilters?: FilterRule[]; + dashboardViewId?: string; }; export const CertificatesSection = ({ externalFilter, - dashboardFilters + dashboardFilters, + dashboardViewId }: CertificatesSectionProps) => { const { currentProject } = useProject(); const { mutateAsync: deleteCert } = useDeleteCert(); @@ -152,6 +154,7 @@ export const CertificatesSection = ({ handlePopUpOpen={handlePopUpOpen} externalFilter={externalFilter} dashboardFilters={dashboardFilters} + dashboardViewId={dashboardViewId} /> diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 8f8d69c32be..2ac7db624ea 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -78,6 +78,7 @@ import type { TSystemViewFilters } from "@app/hooks/api/certificateInventoryViews/types"; import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; +import { NON_PQC_KEY_ALGORITHMS, PQC_KEY_ALGORITHMS } from "@app/hooks/api/certificates/constants"; import { CertSource, CertStatus } from "@app/hooks/api/certificates/enums"; import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -126,6 +127,7 @@ type Props = { search?: string; }; dashboardFilters?: FilterRule[]; + dashboardViewId?: string; }; const PER_PAGE_INIT = 25; @@ -155,20 +157,48 @@ const SortIcon = ({ return ; }; -export const CertificatesTable = ({ handlePopUpOpen, externalFilter, dashboardFilters }: Props) => { +export const CertificatesTable = ({ + handlePopUpOpen, + externalFilter, + dashboardFilters, + dashboardViewId +}: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); const [search, setSearch] = useState(externalFilter?.search || ""); const [appliedSearch, setAppliedSearch] = useState(externalFilter?.search || ""); - const [appliedFilters, setAppliedFilters] = useState( - dashboardFilters?.length ? dashboardFilters : [] - ); + const getFiltersForSystemViewId = (viewId: string | undefined): FilterRule[] => { + if (viewId === "system-pqc") { + return [ + { id: "sv-pqc", field: "keyAlgorithm", operator: "in", value: [...PQC_KEY_ALGORITHMS] } + ]; + } + if (viewId === "system-non-pqc") { + return [ + { + id: "sv-non-pqc", + field: "keyAlgorithm", + operator: "in", + value: [...NON_PQC_KEY_ALGORITHMS] + } + ]; + } + return []; + }; + + const [appliedFilters, setAppliedFilters] = useState(() => { + if (dashboardFilters?.length) return dashboardFilters; + return getFiltersForSystemViewId(dashboardViewId); + }); const [pendingFilters, setPendingFilters] = useState([]); const [isFilterOpen, setIsFilterOpen] = useState(false); const hasDashboardFilters = Boolean(dashboardFilters?.length); + const hasSynchronousDashboardView = + dashboardViewId === "system-pqc" || dashboardViewId === "system-non-pqc"; const [activeViewId, setActiveViewId] = useState(() => { + if (dashboardViewId) return dashboardViewId; if (hasDashboardFilters) return null; try { return localStorage.getItem(VIEW_STORAGE_KEY) || "system-all"; @@ -177,7 +207,9 @@ export const CertificatesTable = ({ handlePopUpOpen, externalFilter, dashboardFi } }); const [isSaveViewOpen, setIsSaveViewOpen] = useState(false); - const [hasRestoredView, setHasRestoredView] = useState(hasDashboardFilters); + const [hasRestoredView, setHasRestoredView] = useState( + hasDashboardFilters || hasSynchronousDashboardView + ); const [sortBy, setSortBy] = useState(undefined); const [sortOrder, setSortOrder] = useState<"asc" | "desc" | undefined>(undefined); @@ -403,6 +435,24 @@ export const CertificatesTable = ({ handlePopUpOpen, externalFilter, dashboardFi setAppliedFilters([ { id: "sv-status", field: "status", operator: "in", value: ["revoked"] } ]); + } else if (viewId === "system-pqc") { + setAppliedFilters([ + { + id: "sv-pqc", + field: "keyAlgorithm", + operator: "in", + value: [...PQC_KEY_ALGORITHMS] + } + ]); + } else if (viewId === "system-non-pqc") { + setAppliedFilters([ + { + id: "sv-non-pqc", + field: "keyAlgorithm", + operator: "in", + value: [...NON_PQC_KEY_ALGORITHMS] + } + ]); } else { const customFilters = filters as TInventoryViewFilters; const rules: FilterRule[] = []; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/inventory-types.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/inventory-types.ts index 0375ebf1d54..652e45f12eb 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/inventory-types.ts +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/inventory-types.ts @@ -68,7 +68,10 @@ export const FILTER_FIELDS: FilterFieldDefinition[] = [ { value: "RSA_4096", label: "RSA-4096" }, { value: "EC_prime256v1", label: "ECDSA-P256" }, { value: "EC_secp384r1", label: "ECDSA-P384" }, - { value: "EC_secp521r1", label: "ECDSA-P521" } + { value: "EC_secp521r1", label: "ECDSA-P521" }, + { value: "ML-DSA-44", label: "ML-DSA-44" }, + { value: "ML-DSA-65", label: "ML-DSA-65" }, + { value: "ML-DSA-87", label: "ML-DSA-87" } ] }, { diff --git a/frontend/src/pages/cert-manager/DashboardPage/DashboardPage.tsx b/frontend/src/pages/cert-manager/DashboardPage/DashboardPage.tsx index ddd55f19adf..b487fc781bc 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/DashboardPage.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/DashboardPage.tsx @@ -11,7 +11,11 @@ import { ProjectPermissionCertificateActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; -import { useGetCertActivityTrend, useGetCertDashboardStats } from "@app/hooks/api/certificates"; +import { + useGetCertActivityTrend, + useGetCertDashboardStats, + useGetCertPqcTrend +} from "@app/hooks/api/certificates"; import { ProjectType } from "@app/hooks/api/projects/types"; import { @@ -20,6 +24,8 @@ import { DistributionCharts, ExpirationTimeline, KpiCards, + PqcReadinessChart, + PqcTrend, ValidityReadinessSection } from "./components"; @@ -28,10 +34,12 @@ export const DashboardPage = () => { const { currentProject } = useProject(); const navigate = useNavigate(); const [trendRange, setTrendRange] = useState("30d"); + const [pqcTrendRange, setPqcTrendRange] = useState("30d"); const { data: stats, isPending: isStatsLoading } = useGetCertDashboardStats( currentProject?.id || "" ); const { data: trendData } = useGetCertActivityTrend(currentProject?.id || "", trendRange); + const { data: pqcTrendData } = useGetCertPqcTrend(currentProject?.id || "", pqcTrendRange); const navigateToInventory = useCallback( (filters: Record) => { navigate({ @@ -85,6 +93,21 @@ export const DashboardPage = () => { onRangeChange={setTrendRange} /> + {stats.totals.total > 0 && ( +
+

+ Post-Quantum Readiness +

+
+ + +
+
+ )} )} diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/PqcReadinessChart.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/PqcReadinessChart.tsx new file mode 100644 index 00000000000..8389a618f17 --- /dev/null +++ b/frontend/src/pages/cert-manager/DashboardPage/components/PqcReadinessChart.tsx @@ -0,0 +1,147 @@ +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip as RechartsTooltip } from "recharts"; + +import { + UnstableCard, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableEmpty, + UnstableEmptyHeader, + UnstableEmptyTitle +} from "@app/components/v3"; +import type { TDashboardStats } from "@app/hooks/api/certificates"; +import { isPqcAlgorithm } from "@app/hooks/api/certificates/constants"; + +import { CHART_COLORS, CHART_COLORS_HEX } from "./chart-theme"; + +type Props = { + stats: TDashboardStats; + onNavigate: (filters: Record) => void; +}; + +const PQC_LABEL = "PQC-ready"; +const CLASSICAL_LABEL = "Classical"; + +export const PqcReadinessChart = ({ stats, onNavigate }: Props) => { + const pqcCount = stats.distributions.byAlgorithm + .filter((d) => isPqcAlgorithm(d.label)) + .reduce((s, d) => s + d.count, 0); + const nonPqcCount = stats.distributions.byAlgorithm + .filter((d) => !isPqcAlgorithm(d.label)) + .reduce((s, d) => s + d.count, 0); + + const data = [ + { label: PQC_LABEL, count: pqcCount }, + { label: CLASSICAL_LABEL, count: nonPqcCount } + ]; + const nonZeroData = data.filter((d) => d.count > 0); + const total = pqcCount + nonPqcCount; + + const handleSegmentClick = (label: string) => { + onNavigate({ viewId: label === PQC_LABEL ? "system-pqc" : "system-non-pqc" }); + }; + + return ( + + + PQC Readiness + + Post-quantum vs. classical key algorithms + + + + {nonZeroData.length === 0 ? ( + + + No data available + + + ) : ( +
+
+ + + + {nonZeroData.map((entry, idx) => { + const hex = CHART_COLORS_HEX[idx % CHART_COLORS_HEX.length]; + return ( + + + + + ); + })} + + handleSegmentClick(nonZeroData[idx].label)} + > + {nonZeroData.map((entry, idx) => ( + + ))} + + + + +
+
+
+ {nonZeroData.map((entry, idx) => { + const pct = total > 0 ? Math.round((entry.count / total) * 100) : 0; + return ( + + ); + })} +
+
+ Total + {total} +
+
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx new file mode 100644 index 00000000000..1c86e116dce --- /dev/null +++ b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx @@ -0,0 +1,129 @@ +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "recharts"; + +import { + Button, + UnstableCard, + UnstableCardAction, + UnstableCardContent, + UnstableCardHeader, + UnstableCardTitle, + UnstableEmpty, + UnstableEmptyHeader, + UnstableEmptyTitle +} from "@app/components/v3"; +import type { TPqcTrendPoint } from "@app/hooks/api/certificates"; + +import { formatTickLabel, nonZeroDot, TREND_COLORS } from "./chart-theme"; + +type Props = { + data: TPqcTrendPoint[]; + onRangeChange: (range: string) => void; + currentRange: string; +}; + +const SERIES_KEYS = ["pqc", "nonPqc"]; + +const ranges = [ + { label: "7D", value: "7d" }, + { label: "30D", value: "30d" }, + { label: "6M", value: "6m" } +]; + +const legendLabels: Record = { + pqc: "PQC-ready", + nonPqc: "Classical" +}; + +const renderLegend = (value: string) => ( + {legendLabels[value] ?? value} +); + +export const PqcTrend = ({ data, onRangeChange, currentRange }: Props) => { + const hasAnyData = data.some((d) => d.pqc > 0 || d.nonPqc > 0); + return ( + + + PQC Adoption Trend + +
+ {ranges.map((r) => ( + + ))} +
+
+
+ + {!hasAnyData ? ( + + + No certificates issued in this period + + + ) : ( + + + + + + [ + value as number, + legendLabels[name as string] ?? (name as string) + ]} + /> + + + + + + )} + +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/chart-theme.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/chart-theme.tsx index 5642f494bb2..68ad561a01c 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/components/chart-theme.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/components/chart-theme.tsx @@ -29,7 +29,9 @@ export const TREND_COLORS = { issued: "var(--color-org)", expired: "var(--color-danger)", revoked: "var(--color-neutral)", - renewed: "var(--color-success)" + renewed: "var(--color-success)", + pqc: "var(--color-info)", + nonPqc: "var(--color-warning)" }; export const formatTickLabel = (value: string) => { diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/index.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/index.tsx index 88efe0c3fb6..adb9fcc0bfd 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/components/index.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/components/index.tsx @@ -3,4 +3,6 @@ export { CodeSigningSection } from "./CodeSigningSection"; export { DistributionCharts } from "./DistributionCharts"; export { ExpirationTimeline } from "./ExpirationTimeline"; export { KpiCards } from "./KpiCards"; +export { PqcReadinessChart } from "./PqcReadinessChart"; +export { PqcTrend } from "./PqcTrend"; export { ValidityReadinessSection } from "./ValidityReadinessSection"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index 33f686071d9..9062c005ca9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -145,6 +145,7 @@ export const PoliciesPage = () => { ) : ( diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx index a84904f4a63..70d0b2d2c7f 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx @@ -7,10 +7,15 @@ type Props = { search?: string; }; dashboardFilters?: FilterRule[]; + dashboardViewId?: string; }; -export const CertificatesTab = ({ externalFilter, dashboardFilters }: Props) => { +export const CertificatesTab = ({ externalFilter, dashboardFilters, dashboardViewId }: Props) => { return ( - + ); }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx index cc05db101b1..4d529224ad1 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx @@ -11,7 +11,8 @@ const policiesPageSearchSchema = z.object({ filterKeyAlgorithm: z.string().optional(), filterCaId: z.string().optional(), filterExpiresDays: z.string().optional(), - filterExpiresAfterDays: z.string().optional() + filterExpiresAfterDays: z.string().optional(), + viewId: z.string().optional() }); export const Route = createFileRoute( From 9ba3ae7ea06569b54db787b93694433140d7ac29 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 18 Apr 2026 02:53:33 +0530 Subject: [PATCH 2/4] fix(pki): stretch KPI cards to equal height on dashboard UnstableCard defaults to h-fit which disables flex items-stretch; other dashboard cards override with h-auto but KpiCards didn't, so cards without a badge (Expiring Soon, Expired) rendered shorter than the rest. --- .../pages/cert-manager/DashboardPage/components/KpiCards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/KpiCards.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/KpiCards.tsx index 426ec310056..cba4a740546 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/components/KpiCards.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/components/KpiCards.tsx @@ -103,7 +103,7 @@ export const KpiCards = ({ stats, onNavigate }: Props) => { return ( onNavigate(card.filters)} > From 1fb5061ada5c2830ae9a4d325348b25b7a1d30d6 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 18 Apr 2026 02:55:38 +0530 Subject: [PATCH 3/4] fix(pki): stretch PQC trend card to match pie in same row Missed h-auto on PqcTrend so it kept h-fit from UnstableCard default, breaking stretch alignment with PqcReadinessChart beside it. --- .../pages/cert-manager/DashboardPage/components/PqcTrend.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx index 1c86e116dce..fd3d1cecce6 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx @@ -50,7 +50,7 @@ const renderLegend = (value: string) => ( export const PqcTrend = ({ data, onRangeChange, currentRange }: Props) => { const hasAnyData = data.some((d) => d.pqc > 0 || d.nonPqc > 0); return ( - + PQC Adoption Trend From 195a0b7927b910fb495b14f2ecbe562257f50491 Mon Sep 17 00:00:00 2001 From: Saif Date: Sat, 18 Apr 2026 02:58:45 +0530 Subject: [PATCH 4/4] fix(pki): let PQC trend card shrink with viewport min-w-[400px] prevented the trend card from shrinking below 400px, causing overflow/clipping at narrow widths. Drop the min-width and let it behave like ActivityTrend. --- .../pages/cert-manager/DashboardPage/components/PqcTrend.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx index fd3d1cecce6..de8adffa892 100644 --- a/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx +++ b/frontend/src/pages/cert-manager/DashboardPage/components/PqcTrend.tsx @@ -50,7 +50,7 @@ const renderLegend = (value: string) => ( export const PqcTrend = ({ data, onRangeChange, currentRange }: Props) => { const hasAnyData = data.some((d) => d.pqc > 0 || d.nonPqc > 0); return ( - + PQC Adoption Trend