From 1ad329f9cf3123f2a9afc25cfb5301e29004c62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 11 May 2026 09:53:55 +0200 Subject: [PATCH] feat(ui): ThreatScore compliance views pillars, nav + charts (#10975) Co-authored-by: alejandrobailo Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> --- ui/CHANGELOG.md | 3 +- .../compliance/[compliancetitle]/page.tsx | 25 +++- .../client-accordion-wrapper.tsx | 32 ++++- .../threatscore-breakdown-card.tsx | 43 ++++--- .../top-failed-sections-card.tsx | 11 +- .../compliance/threatscore-badge.tsx | 84 +++++++++---- ui/components/graphs/donut-chart.tsx | 46 ++++++- ui/components/graphs/horizontal-bar-chart.tsx | 9 +- ui/components/ui/accordion/Accordion.tsx | 1 + ui/lib/compliance/compliance-mapper.ts | 3 +- ui/lib/compliance/threat-helpers.ts | 47 ++++++++ ui/lib/compliance/threat.test.ts | 100 ++++++++++++++++ ui/lib/compliance/threat.tsx | 17 ++- ui/lib/compliance/threatscore-pillars.test.ts | 112 ++++++++++++++++++ ui/lib/compliance/threatscore-pillars.ts | 75 ++++++++++++ ui/types/compliance.ts | 5 + 16 files changed, 549 insertions(+), 64 deletions(-) create mode 100644 ui/lib/compliance/threat-helpers.ts create mode 100644 ui/lib/compliance/threat.test.ts create mode 100644 ui/lib/compliance/threatscore-pillars.test.ts create mode 100644 ui/lib/compliance/threatscore-pillars.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 5def490fa2..eade2eb169 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,12 +2,13 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.26.0] (Prowler UNRELEASED) +## [1.26.0] (Prowler v5.26.0) ### 🔄 Changed - Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971) - Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) +- ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975) --- diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 263385c006..71e7870f8d 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -42,6 +42,7 @@ interface ComplianceDetailSearchParams { complianceId: string; version?: string; scanId?: string; + section?: string; "filter[region__in]"?: string; "filter[cis_profile_level]"?: string; page?: string; @@ -57,7 +58,7 @@ export default async function ComplianceDetail({ }) { const { compliancetitle } = await params; const resolvedSearchParams = await searchParams; - const { complianceId, version, scanId } = resolvedSearchParams; + const { complianceId, version, scanId, section } = resolvedSearchParams; const regionFilter = resolvedSearchParams["filter[region__in]"]; const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); @@ -225,6 +226,7 @@ export default async function ComplianceDetail({ filter={cisProfileFilter} attributesData={attributesData} threatScoreData={threatScoreData} + targetSection={section} /> @@ -238,6 +240,7 @@ const SSRComplianceContent = async ({ filter, attributesData, threatScoreData, + targetSection, }: { complianceId: string; scanId: string; @@ -248,6 +251,7 @@ const SSRComplianceContent = async ({ overallScore: number; sectionScores: Record; } | null; + targetSection?: string; }) => { const requirementsData = await getComplianceRequirements({ complianceId, @@ -288,6 +292,21 @@ const SSRComplianceContent = async ({ const accordionItems = mapper.toAccordionItems(data, scanId); const topFailedResult = mapper.getTopFailedSections(data); + // Resolve which accordion key matches the requested ?section= so we can + // auto-expand it on first render. Each mapper builds keys as + // `${framework.name}-${category.name}`; rebuild the exact candidates here + // to avoid suffix collisions across frameworks or category names. + const initialExpandedKeys: string[] = []; + if (targetSection) { + const candidates = new Set( + data.map((f: Framework) => `${f.name}-${targetSection}`), + ); + const match = accordionItems.find((item) => candidates.has(item.key)); + if (match) { + initialExpandedKeys.push(match.key); + } + } + return (
{/* Charts section */} @@ -315,6 +334,7 @@ const SSRComplianceContent = async ({ {/* */}
@@ -323,7 +343,8 @@ const SSRComplianceContent = async ({ ); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx index 47e17c35ee..4682f039e5 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "@/components/shadcn"; import { Accordion, AccordionItemProps } from "@/components/ui"; @@ -9,10 +9,12 @@ export const ClientAccordionWrapper = ({ items, defaultExpandedKeys, hideExpandButton = false, + scrollToKey, }: { items: AccordionItemProps[]; defaultExpandedKeys: string[]; hideExpandButton?: boolean; + scrollToKey?: string; }) => { const [selectedKeys, setSelectedKeys] = useState(defaultExpandedKeys); @@ -56,8 +58,33 @@ export const ClientAccordionWrapper = ({ setSelectedKeys(keys); }; + // Tracks the last `scrollToKey` we already scrolled to so the inline + // callback ref below stays idempotent. Without this flag React would + // re-fire the scroll on every state change (Expand all, row toggle, + // parent re-render) because the callback ref's identity changes per + // render and React re-attaches it. + const lastScrolledKeyRef = useRef(null); + + const containerRef = (node: HTMLDivElement | null) => { + if (!node || !scrollToKey) return; + if (lastScrolledKeyRef.current === scrollToKey) return; + lastScrolledKeyRef.current = scrollToKey; + // Two nested rAFs: the first lets the accordion children commit to + // the DOM, the second lands after the browser has run a layout pass + // so HeroUI's framer-motion expand has settled enough for + // scrollIntoView to read a stable offset. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const target = node.querySelector( + `[data-accordion-key="${CSS.escape(scrollToKey)}"]`, + ); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }); + }; + return ( -
+
{!hideExpandButton && (
- {sortedSections.map(([section, score]) => ( -
+ {pillars.map(({ name, score, hasData }) => ( +
- - {section} - - - {score.toFixed(1)}% + {name} + + {hasData ? `${score.toFixed(1)}%` : "—"}
sum + section.total, 0); const barData: BarDataPoint[] = sections.map((section) => ({ @@ -39,7 +43,10 @@ export function TopFailedSectionsCard({ {title} - + ); diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx index 89645041c6..01bf180c9f 100644 --- a/ui/components/compliance/threatscore-badge.tsx +++ b/ui/components/compliance/threatscore-badge.tsx @@ -19,6 +19,10 @@ import { getScoreIndicatorClass, getScoreTextClass, } from "@/lib/compliance/score-utils"; +import { + getOrderedPillars, + THREATSCORE_SECTION_PARAM, +} from "@/lib/compliance/threatscore-pillars"; import { downloadComplianceCsv, downloadComplianceReportPdf, @@ -46,7 +50,7 @@ export const ThreatScoreBadge = ({ const complianceId = `prowler_threatscore_${provider.toLowerCase()}`; - const handleCardClick = () => { + const buildDetailHref = (section?: string) => { const title = "ProwlerThreatScore"; const version = "1.0"; const formattedTitleForUrl = encodeURIComponent(title); @@ -62,9 +66,23 @@ export const ThreatScoreBadge = ({ params.set("filter[region__in]", regionFilter); } - router.push(`${path}?${params.toString()}`); + if (section) { + params.set(THREATSCORE_SECTION_PARAM, section); + } + + return `${path}?${params.toString()}`; }; + const handleCardClick = () => { + router.push(buildDetailHref()); + }; + + const handlePillarClick = (section: string) => { + router.push(buildDetailHref(section)); + }; + + const pillars = getOrderedPillars(sectionScores); + const handleDownloadCsv = async () => { if (isDownloadingCsv) return; setIsDownloadingCsv(true); @@ -113,31 +131,45 @@ export const ThreatScoreBadge = ({
- {/* Pillar breakdown — always visible */} - {sectionScores && Object.keys(sectionScores).length > 0 && ( + {/* Pillar breakdown — always visible, in canonical order */} + {pillars.length > 0 && (
- {Object.entries(sectionScores) - .sort(([, a], [, b]) => a - b) - .map(([section, sectionScore]) => ( -
- - {section} - - - - {sectionScore.toFixed(1)}% - -
- ))} + {pillars.map(({ name, score: sectionScore, hasData }) => ( + + ))}
)} diff --git a/ui/components/graphs/donut-chart.tsx b/ui/components/graphs/donut-chart.tsx index f0f9e3a2a5..796e2d9bc2 100644 --- a/ui/components/graphs/donut-chart.tsx +++ b/ui/components/graphs/donut-chart.tsx @@ -1,7 +1,15 @@ "use client"; import { useState } from "react"; -import { Cell, Label, Pie, PieChart, Tooltip } from "recharts"; +import { + Cell, + Label, + Pie, + PieChart, + Sector, + type SectorProps, + Tooltip, +} from "recharts"; import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart"; @@ -156,6 +164,22 @@ export function DonutChart({ }, })); + // Reserve a small ring at the outer edge so the active sector can grow into + // it without being clipped by the SVG viewport (consumers like + // RequirementsStatusCard wrap the chart in a fixed-size box where + // outerRadius == container/2 leaves no room to expand). + const ACTIVE_GROW = 4; + const restingOuterRadius = Math.max( + innerRadius + 1, + outerRadius - ACTIVE_GROW, + ); + + // Grows the hovered slice up to the original outerRadius so tiny segments + // (e.g. 1% fail) are easy to see and target with the cursor. + const renderActiveShape = (props: SectorProps) => ( + + ); + return ( <> - {!isEmpty && } />} + {!isEmpty && ( + } + cursor={false} + wrapperStyle={{ zIndex: 1000 }} + /> + )} { + if (!isEmpty) setHoveredIndex(index); + }} + onMouseLeave={() => setHoveredIndex(null)} > {(isEmpty ? emptyData : chartData).map((entry, index) => { const opacity = @@ -186,8 +224,6 @@ export function DonutChart({ style={{ transition: "opacity 0.2s", }} - onMouseEnter={() => setHoveredIndex(index)} - onMouseLeave={() => setHoveredIndex(null)} onClick={() => { if (isClickable) { onSegmentClick(data[index], index); diff --git a/ui/components/graphs/horizontal-bar-chart.tsx b/ui/components/graphs/horizontal-bar-chart.tsx index a7b07ca616..9eaaa4784a 100644 --- a/ui/components/graphs/horizontal-bar-chart.tsx +++ b/ui/components/graphs/horizontal-bar-chart.tsx @@ -14,17 +14,24 @@ interface HorizontalBarChartProps { height?: number; title?: string; onBarClick?: (dataPoint: BarDataPoint, index: number) => void; + /** + * When false, totals of 0 still render the supplied `data` as zero-width + * bars instead of falling back to severity placeholders. Useful for callers + * that pre-populate a canonical category list (e.g. ThreatScore pillars). + */ + useSeverityEmptyState?: boolean; } export function HorizontalBarChart({ data, title, onBarClick, + useSeverityEmptyState = true, }: HorizontalBarChartProps) { const [hoveredIndex, setHoveredIndex] = useState(null); const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0); - const isEmpty = total <= 0; + const isEmpty = total <= 0 && (useSeverityEmptyState || data.length === 0); const emptyData: BarDataPoint[] = [ { name: "Critical", value: 1, percentage: 100 }, diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx index 1907c940c4..89403aa08d 100644 --- a/ui/components/ui/accordion/Accordion.tsx +++ b/ui/components/ui/accordion/Accordion.tsx @@ -134,6 +134,7 @@ export const Accordion = ({ {items.map((item, index) => ( => ({ ProwlerThreatScore: { mapComplianceData: mapThetaComplianceData, toAccordionItems: toThetaAccordionItems, - getTopFailedSections, + getTopFailedSections: getThreatScoreTopFailedSections, calculateCategoryHeatmapData: (complianceData: Framework[]) => calculateCategoryHeatmapData(complianceData), getDetailsComponent: (requirement: Requirement) => diff --git a/ui/lib/compliance/threat-helpers.ts b/ui/lib/compliance/threat-helpers.ts new file mode 100644 index 0000000000..a50ea46a0f --- /dev/null +++ b/ui/lib/compliance/threat-helpers.ts @@ -0,0 +1,47 @@ +import { + FailedSection, + Framework, + REQUIREMENT_STATUS, + TOP_FAILED_DATA_TYPE, + TopFailedResult, +} from "@/types/compliance"; + +import { + compareSectionsByCanonicalOrder, + THREATSCORE_PILLARS, +} from "./threatscore-pillars"; + +// Builds the Top Failed Sections data for ThreatScore: every canonical pillar +// is always present (zero-fill) so the chart remains meaningful even when +// only one or two pillars have failures. Sections returned by the data that +// are not in the canonical list are appended afterwards in canonical order. +export const getTopFailedSections = ( + mappedData: Framework[], +): TopFailedResult => { + const totals = new Map(); + const seen = new Set(); + + THREATSCORE_PILLARS.forEach((name) => { + totals.set(name, 0); + seen.add(name); + }); + + mappedData.forEach((framework) => { + framework.categories.forEach((category) => { + seen.add(category.name); + category.controls.forEach((control) => { + control.requirements.forEach((requirement) => { + if (requirement.status === REQUIREMENT_STATUS.FAIL) { + totals.set(category.name, (totals.get(category.name) ?? 0) + 1); + } + }); + }); + }); + }); + + const items: FailedSection[] = Array.from(seen) + .sort(compareSectionsByCanonicalOrder) + .map((name) => ({ name, total: totals.get(name) ?? 0 })); + + return { items, type: TOP_FAILED_DATA_TYPE.SECTIONS, prepopulated: true }; +}; diff --git a/ui/lib/compliance/threat.test.ts b/ui/lib/compliance/threat.test.ts new file mode 100644 index 0000000000..17b15bec71 --- /dev/null +++ b/ui/lib/compliance/threat.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { Framework, REQUIREMENT_STATUS } from "@/types/compliance"; + +import { getTopFailedSections } from "./threat-helpers"; +import { THREATSCORE_PILLARS } from "./threatscore-pillars"; + +const buildFramework = ( + categoriesSpec: Array<{ + name: string; + statuses: Array<"PASS" | "FAIL" | "MANUAL">; + }>, +): Framework => ({ + name: "ProwlerThreatScore", + pass: 0, + fail: 0, + manual: 0, + categories: categoriesSpec.map((spec) => ({ + name: spec.name, + pass: 0, + fail: 0, + manual: 0, + controls: [ + { + label: "control-0", + pass: 0, + fail: 0, + manual: 0, + requirements: spec.statuses.map((status, i) => ({ + name: `${spec.name}-req-${i}`, + description: "", + status: REQUIREMENT_STATUS[status], + check_ids: [], + pass: 0, + fail: 0, + manual: 0, + })), + }, + ], + })), +}); + +describe("threat.getTopFailedSections", () => { + it("returns every canonical pillar with zero-fill when no failures", () => { + const data = [buildFramework([{ name: "1. IAM", statuses: ["PASS"] }])]; + const result = getTopFailedSections(data); + + expect(result.items.map((i) => i.name)).toEqual([...THREATSCORE_PILLARS]); + expect(result.items.every((i) => i.total === 0)).toBe(true); + }); + + it("counts FAIL requirements per category and keeps canonical order", () => { + const data = [ + buildFramework([ + { name: "1. IAM", statuses: ["FAIL", "FAIL"] }, + { name: "4. Encryption", statuses: ["FAIL"] }, + ]), + ]; + const result = getTopFailedSections(data); + + expect(result.items).toEqual([ + { name: "1. IAM", total: 2 }, + { name: "2. Attack Surface", total: 0 }, + { name: "3. Logging and Monitoring", total: 0 }, + { name: "4. Encryption", total: 1 }, + ]); + }); + + it("appends non-canonical sections after the canonical ones", () => { + const data = [ + buildFramework([ + { name: "1. IAM", statuses: ["FAIL"] }, + { name: "5. Data Protection", statuses: ["FAIL", "FAIL"] }, + ]), + ]; + const result = getTopFailedSections(data); + + expect(result.items.map((i) => i.name)).toEqual([ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", + "5. Data Protection", + ]); + expect( + result.items.find((i) => i.name === "5. Data Protection")?.total, + ).toBe(2); + }); + + it("ignores PASS and MANUAL when counting failures", () => { + const data = [ + buildFramework([ + { name: "1. IAM", statuses: ["PASS", "MANUAL", "FAIL", "PASS"] }, + ]), + ]; + const result = getTopFailedSections(data); + + expect(result.items.find((i) => i.name === "1. IAM")?.total).toBe(1); + }); +}); diff --git a/ui/lib/compliance/threat.tsx b/ui/lib/compliance/threat.tsx index a523b1806e..08e4951a63 100644 --- a/ui/lib/compliance/threat.tsx +++ b/ui/lib/compliance/threat.tsx @@ -20,6 +20,9 @@ import { findOrCreateFramework, updateCounters, } from "./commons"; +import { compareSectionsByCanonicalOrder } from "./threatscore-pillars"; + +export { getTopFailedSections } from "./threat-helpers"; export const mapComplianceData = ( attributesData: AttributesData, @@ -91,6 +94,14 @@ export const mapComplianceData = ( control.requirements.push(requirement); } + // Sort categories within each framework by canonical pillar order so + // the accordion, charts and breakdown all agree on the same ordering. + frameworks.forEach((framework) => { + framework.categories.sort((a, b) => + compareSectionsByCanonicalOrder(a.name, b.name), + ); + }); + // Calculate counters and percentualScore (Threat-specific logic) frameworks.forEach((framework) => { framework.pass = 0; @@ -149,9 +160,7 @@ export const mapComplianceData = ( ? (numerator / denominator) * 100 : 0; - // Add percentualScore to category (we can extend the type or use a custom property) - (category as any).percentualScore = - Math.round(percentualScore * 100) / 100; // Round to 2 decimal places + category.percentualScore = Math.round(percentualScore * 100) / 100; framework.pass += category.pass; framework.fail += category.fail; @@ -168,7 +177,7 @@ export const toAccordionItems = ( ): AccordionItemProps[] => { return data.flatMap((framework) => framework.categories.map((category) => { - const percentualScore = (category as any).percentualScore || 0; + const percentualScore = category.percentualScore ?? 0; return { key: `${framework.name}-${category.name}`, diff --git a/ui/lib/compliance/threatscore-pillars.test.ts b/ui/lib/compliance/threatscore-pillars.test.ts new file mode 100644 index 0000000000..e33cdc629f --- /dev/null +++ b/ui/lib/compliance/threatscore-pillars.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { + compareSectionsByCanonicalOrder, + getOrderedPillars, + THREATSCORE_PILLARS, +} from "./threatscore-pillars"; + +describe("getOrderedPillars", () => { + it("returns every canonical pillar in canonical order, treating missing canonical pillars as 100% (no findings = secure)", () => { + const result = getOrderedPillars({ "1. IAM": 90, "4. Encryption": 60 }); + + expect(result.map((p) => p.name)).toEqual([...THREATSCORE_PILLARS]); + expect(result[0]).toEqual({ name: "1. IAM", score: 90, hasData: true }); + expect(result[1]).toEqual({ + name: "2. Attack Surface", + score: 100, + hasData: true, + }); + expect(result[2]).toEqual({ + name: "3. Logging and Monitoring", + score: 100, + hasData: true, + }); + expect(result[3]).toEqual({ + name: "4. Encryption", + score: 60, + hasData: true, + }); + }); + + it("appends non-canonical sections after the canonical ones, sorted naturally", () => { + const result = getOrderedPillars({ + "1. IAM": 50, + "10. Future Pillar": 70, + "5. Data Protection": 80, + }); + + expect(result.map((p) => p.name)).toEqual([ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", + "5. Data Protection", + "10. Future Pillar", + ]); + }); + + it("handles undefined sectionScores gracefully", () => { + const result = getOrderedPillars(undefined); + + expect(result).toHaveLength(THREATSCORE_PILLARS.length); + expect(result.every((p) => !p.hasData)).toBe(true); + }); + + it("treats non-numeric or non-finite scores as missing data", () => { + // Defensive: API contract is Record, but null/string/NaN + // should never crash a `score.toFixed(...)` consumer. + const result = getOrderedPillars({ + "1. IAM": Number.NaN as unknown as number, + "2. Attack Surface": null as unknown as number, + "3. Logging and Monitoring": "80" as unknown as number, + "4. Encryption": 60, + }); + + expect(result[0]).toEqual({ name: "1. IAM", score: 0, hasData: false }); + expect(result[1]).toEqual({ + name: "2. Attack Surface", + score: 0, + hasData: false, + }); + expect(result[2]).toEqual({ + name: "3. Logging and Monitoring", + score: 0, + hasData: false, + }); + expect(result[3]).toEqual({ + name: "4. Encryption", + score: 60, + hasData: true, + }); + }); +}); + +describe("compareSectionsByCanonicalOrder", () => { + it("orders canonical pillars by their declared position", () => { + const sections = [ + "4. Encryption", + "2. Attack Surface", + "1. IAM", + "3. Logging and Monitoring", + ]; + sections.sort(compareSectionsByCanonicalOrder); + expect(sections).toEqual([...THREATSCORE_PILLARS]); + }); + + it("places unknown sections after canonical ones, in natural order", () => { + const sections = [ + "Custom Section", + "10. Tenth", + "1. IAM", + "5. Data Protection", + ]; + sections.sort(compareSectionsByCanonicalOrder); + expect(sections).toEqual([ + "1. IAM", + "5. Data Protection", + "10. Tenth", + "Custom Section", + ]); + }); +}); diff --git a/ui/lib/compliance/threatscore-pillars.ts b/ui/lib/compliance/threatscore-pillars.ts new file mode 100644 index 0000000000..e4b227e61f --- /dev/null +++ b/ui/lib/compliance/threatscore-pillars.ts @@ -0,0 +1,75 @@ +import type { SectionScores } from "@/actions/overview/threat-score"; + +export const THREATSCORE_PILLARS = [ + "1. IAM", + "2. Attack Surface", + "3. Logging and Monitoring", + "4. Encryption", +] as const; + +export interface OrderedPillar { + name: string; + score: number; + hasData: boolean; +} + +const compareNatural = (a: string, b: string) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + +// API contract is `Record`, but defensively coerce so a +// future null/string value cannot blow up `score.toFixed(...)` callers. +// `treatMissingAsFull` makes a missing canonical pillar mean "no findings → +// 100%" rather than "no data". Only safe when `sectionScores` is provided +// (i.e. the scan ran); when undefined we still surface "no data". +const readScore = ( + scores: SectionScores, + name: string, + treatMissingAsFull: boolean, +): { score: number; hasData: boolean } => { + const raw = scores[name]; + if (typeof raw === "number" && Number.isFinite(raw)) { + return { score: raw, hasData: true }; + } + if (treatMissingAsFull && raw === undefined) { + return { score: 100, hasData: true }; + } + return { score: 0, hasData: false }; +}; + +export function getOrderedPillars( + sectionScores?: SectionScores, +): OrderedPillar[] { + const scores = sectionScores ?? {}; + const treatMissingAsFull = sectionScores !== undefined; + const remaining = new Set(Object.keys(scores)); + + const canonical: OrderedPillar[] = THREATSCORE_PILLARS.map((name) => { + remaining.delete(name); + const { score, hasData } = readScore(scores, name, treatMissingAsFull); + return { name, score, hasData }; + }); + + const extras: OrderedPillar[] = Array.from(remaining) + .sort(compareNatural) + .map((name) => { + const { score, hasData } = readScore(scores, name, false); + return { name, score, hasData }; + }); + + return [...canonical, ...extras]; +} + +export const THREATSCORE_SECTION_PARAM = "section"; + +export const compareSectionsByCanonicalOrder = (a: string, b: string) => { + const indexA = THREATSCORE_PILLARS.indexOf( + a as (typeof THREATSCORE_PILLARS)[number], + ); + const indexB = THREATSCORE_PILLARS.indexOf( + b as (typeof THREATSCORE_PILLARS)[number], + ); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return compareNatural(a, b); +}; diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 1557671537..e625cedf5a 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -60,6 +60,7 @@ export interface Category { fail: number; manual: number; controls: Control[]; + percentualScore?: number; } export interface Framework { @@ -89,6 +90,10 @@ export type TopFailedDataType = export interface TopFailedResult { items: FailedSection[]; type: TopFailedDataType; + // True when items already cover every relevant category (zero-fill). The + // chart should render the supplied list as-is instead of falling back to + // severity placeholders when totals are zero. + prepopulated?: boolean; } export interface RequirementsTotals {