feat(ui): add UI changes related with PDF generation

This commit is contained in:
pedrooot committed 2026-07-06 12:14:07 +02:00
1 parent 0130764a0e
commit e2a6d1a887
40 files changed
+2609 -184

No files matched your search

+297 -6
View File
@@ -1,8 +1,35 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import type { ScanBinaryResult } from "@/actions/scans";
import {
apiBaseUrl,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
async function getCrossProviderPdfErrorMessage(
response: Response,
fallbackMessage: string,
): Promise<string> {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("text/html")) {
return GENERIC_SERVER_ERROR_MESSAGE;
}
const errorData = await response.json().catch(() => null);
return (
errorData?.errors?.[0]?.detail ||
errorData?.errors?.detail ||
errorData?.error ||
errorData?.message ||
(response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage)
);
}
export const getCompliancesOverview = async ({
scanId,
region,
@@ -34,7 +61,7 @@ export const getCompliancesOverview = async ({
headers,
});
return handleApiResponse(response);
return await handleApiResponse(response);
} catch (error) {
console.error("Error fetching compliances overview:", error);
return undefined;
@@ -66,7 +93,7 @@ export const getComplianceOverviewMetadataInfo = async ({
headers,
});
return handleApiResponse(response);
return await handleApiResponse(response);
} catch (error) {
console.error("Error fetching compliance overview metadata info:", error);
return undefined;
@@ -100,7 +127,7 @@ export const getComplianceAttributes = async (
return { warming: true as const, status: 503 };
}
return handleApiResponse(response);
return await handleApiResponse(response);
} catch (error) {
console.error("Error fetching compliance attributes:", error);
return undefined;
@@ -134,7 +161,7 @@ export const getComplianceRequirements = async ({
headers,
});
return handleApiResponse(response);
return await handleApiResponse(response);
} catch (error) {
console.error("Error fetching compliance requirements:", error);
return undefined;
@@ -155,11 +182,15 @@ export const getCrossProviderComplianceOverview = async ({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
regions,
}: {
complianceId: string;
scanIds?: string[];
providerTypes?: string | string[];
providerIds?: string | string[];
providerGroups?: string | string[];
regions?: string | string[];
}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -179,6 +210,8 @@ export const getCrossProviderComplianceOverview = async ({
setParam("filter[scan__in]", scanIds);
}
setParam("filter[provider_type__in]", providerTypes);
setParam("filter[provider_id__in]", providerIds);
setParam("filter[provider_groups__in]", providerGroups);
setParam("filter[region__in]", regions);
try {
@@ -186,9 +219,267 @@ export const getCrossProviderComplianceOverview = async ({
if (response.status === 402) return { redirectTo: "/billing" };
return handleApiResponse(response);
return await handleApiResponse(response);
} catch (error) {
console.error("Error fetching cross-provider compliance overview:", error);
return undefined;
}
};
/**
* Trigger ad-hoc generation of the combined cross-provider compliance PDF.
*
* Backed by ``POST /cross-provider-compliance-overviews/generate-pdf``. The
* PDF itself is built asynchronously by a Celery task and is not ready when
* this call returns — it dispatches the job and returns the ``Task`` id so
* the caller can poll it (via ``getTask``) and then fetch the finished file
* with {@link getCrossProviderCompliancePdf}.
*
* ``scanIds`` should be the exact scan ids the caller currently has on
* screen (``attributes.scan_ids`` from the overview response) so the PDF
* matches what the user is looking at instead of re-resolving "latest scan
* per provider" a second time, which could race a scan that completes
* in between.
*
* ``providerTypes``/``providerIds``/``providerGroups`` should be the same
* ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` values currently applied to the on-screen
* cross-provider view (same shape ``getCrossProviderComplianceOverview``
* takes) so the generated PDF only includes the providers the user has
* filtered down to, instead of silently widening back out to every
* compatible provider.
*/
export const generateCrossProviderCompliancePdf = async ({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
onlyFailed,
includeManual,
reportName,
}: {
complianceId: string;
scanIds?: string[];
providerTypes?: string | string[];
providerIds?: string | string[];
providerGroups?: string | string[];
onlyFailed?: boolean;
includeManual?: boolean;
/** Optional user-chosen download filename. Sanitized server-side (a
* `.pdf` extension is enforced); omit for a unique timestamped default. */
reportName?: string;
}): Promise<{ taskId: string } | { error: string }> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf`,
);
const setParam = (key: string, value?: string | string[]) => {
if (!value) return;
const serializedValue = Array.isArray(value) ? value.join(",") : value;
if (serializedValue.trim().length > 0) {
url.searchParams.set(key, serializedValue);
}
};
url.searchParams.set("filter[compliance_id]", complianceId);
if (scanIds && scanIds.length > 0) {
url.searchParams.set("filter[scan__in]", scanIds.join(","));
}
setParam("filter[provider_type__in]", providerTypes);
setParam("filter[provider_id__in]", providerIds);
setParam("filter[provider_groups__in]", providerGroups);
setParam("report_name", reportName);
if (onlyFailed !== undefined) {
url.searchParams.set("only_failed", String(onlyFailed));
}
if (includeManual !== undefined) {
url.searchParams.set("include_manual", String(includeManual));
}
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
});
if (!response.ok) {
throw new Error(
await getCrossProviderPdfErrorMessage(
response,
"Unable to start PDF generation. Contact support if the issue continues.",
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) {
throw new Error("Unexpected response starting PDF generation.");
}
return { taskId };
} catch (error) {
return { error: getErrorMessage(error) };
}
};
/**
* Fetch the finished cross-provider compliance PDF for a task started by
* {@link generateCrossProviderCompliancePdf}.
*
* Backed by ``GET .../generate-pdf/{taskId}/download``, which mirrors the
* same 202-pending / 2xx-binary / error-JSON protocol as the per-scan report
* endpoints — returns a {@link ScanBinaryResult} so callers can reuse
* ``downloadFile`` from ``lib/helper.ts`` unchanged.
*/
export const getCrossProviderCompliancePdf = async (
taskId: string,
): Promise<ScanBinaryResult> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/${taskId}/download`,
);
try {
const response = await fetch(url.toString(), { headers });
if (response.status === 202) {
const json = await response.json();
return {
pending: true,
state: json?.data?.attributes?.state,
taskId: json?.data?.id,
};
}
if (!response.ok) {
throw new Error(
await getCrossProviderPdfErrorMessage(
response,
"Unable to retrieve the compliance PDF report. Contact support if the issue continues.",
),
);
}
const contentDisposition =
response.headers.get("content-disposition") || "";
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch?.[1] || "compliance-report.pdf";
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString("base64");
return { success: true, data: base64, filename };
} catch (error) {
return { error: getErrorMessage(error) };
}
};
/** A previously-generated cross-provider PDF report matching a given set
* of filters, ready to download immediately (no polling required). */
export interface LatestCrossProviderPdfReport {
taskId: string;
filename?: string;
/** ISO timestamp the report finished generating (``completed_at``), if
* the backend provided one. */
generatedAt?: string;
}
export type LatestCrossProviderPdfResult =
| ({ available: true } & LatestCrossProviderPdfReport)
| { available: false }
| { error: string };
/**
* Check whether a cross-provider compliance PDF has already been generated
* for the given filters, so the UI can offer "Download" immediately instead
* of always making the user click "Generate" and wait.
*
* Backed by ``GET .../generate-pdf/latest``: 200 with a Task body means a
* matching, still-current report exists (the backend only matches reports
* generated from the exact scan set these filters currently resolve to —
* a report goes stale and this starts returning "not available" the moment
* any contributing provider completes a new scan); 404 means "not generated
* yet" (a normal, expected state — not surfaced as an error).
*
* Same param shape as {@link generateCrossProviderCompliancePdf} so callers
* can pass through the exact same filter values to both.
*/
export const getLatestCrossProviderCompliancePdf = async ({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
}: {
complianceId: string;
scanIds?: string[];
providerTypes?: string | string[];
providerIds?: string | string[];
providerGroups?: string | string[];
}): Promise<LatestCrossProviderPdfResult> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/cross-provider-compliance-overviews/generate-pdf/latest`,
);
const setParam = (key: string, value?: string | string[]) => {
if (!value) return;
const serializedValue = Array.isArray(value) ? value.join(",") : value;
if (serializedValue.trim().length > 0) {
url.searchParams.set(key, serializedValue);
}
};
url.searchParams.set("filter[compliance_id]", complianceId);
if (scanIds && scanIds.length > 0) {
url.searchParams.set("filter[scan__in]", scanIds.join(","));
}
setParam("filter[provider_type__in]", providerTypes);
setParam("filter[provider_id__in]", providerIds);
setParam("filter[provider_groups__in]", providerGroups);
try {
const response = await fetch(url.toString(), { headers });
if (response.status === 404) {
return { available: false };
}
if (!response.ok) {
throw new Error(
await getCrossProviderPdfErrorMessage(
response,
"Unable to check for an existing PDF report. Contact support if the issue continues.",
),
);
}
const json = await response.json();
const taskId = json?.data?.id;
if (!taskId) {
return { available: false };
}
return {
available: true,
taskId,
filename: json?.data?.attributes?.result?.filename,
generatedAt: json?.data?.attributes?.completed_at,
};
} catch (error) {
// Logged here because the caller (the compliance detail page) degrades
// an ``{error}`` to "no report available" by design — the right UX for
// an optional availability check, but without this log a systematically
// failing endpoint would be indistinguishable from "never generated".
console.error(
"Error checking for an existing cross-provider compliance PDF:",
error,
);
return { error: getErrorMessage(error) };
}
};
+6 -1
View File
@@ -13,7 +13,12 @@ export const getTask = async (taskId: string) => {
headers,
});
return handleApiResponse(response);
// Must be awaited here, not just returned: ``handleApiResponse`` is
// itself async and throws on non-2xx — a bare `return` hands back its
// promise without this function's own `try` ever observing a rejection,
// so `catch` below never runs and callers get an unhandled rejection
// instead of the documented `{error}` shape.
return await handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
@@ -9,8 +9,11 @@ import {
getComplianceRequirements,
getCompliancesOverview,
getCrossProviderComplianceOverview,
getLatestCrossProviderCompliancePdf,
} from "@/actions/compliances";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getThreatScore } from "@/actions/overview";
import { getAllProviders } from "@/actions/providers";
import { getScan } from "@/actions/scans";
import {
ClientAccordionWrapper,
@@ -54,6 +57,8 @@ interface ComplianceDetailSearchParams {
mode?: string;
"filter[region__in]"?: string;
"filter[provider_type__in]"?: string;
"filter[provider_id__in]"?: string;
"filter[provider_groups__in]"?: string;
"filter[cis_profile_level]"?: string;
page?: string;
pageSize?: string;
@@ -71,6 +76,9 @@ export default async function ComplianceDetail({
const { complianceId, version, scanId, section, mode } = resolvedSearchParams;
const regionFilter = resolvedSearchParams["filter[region__in]"];
const providerTypeFilter = resolvedSearchParams["filter[provider_type__in]"];
const providerIdFilter = resolvedSearchParams["filter[provider_id__in]"];
const providerGroupsFilter =
resolvedSearchParams["filter[provider_groups__in]"];
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
@@ -85,11 +93,32 @@ export default async function ComplianceDetail({
}
const crossProviderTitle = compliancetitle.split("-").join(" ");
const crossProviderResponse = await getCrossProviderComplianceOverview({
complianceId,
providerTypes: providerTypeFilter,
regions: regionFilter,
});
const [
crossProviderResponse,
providersData,
providerGroupsData,
latestPdfReport,
] = await Promise.all([
getCrossProviderComplianceOverview({
complianceId,
providerTypes: providerTypeFilter,
providerIds: providerIdFilter,
providerGroups: providerGroupsFilter,
regions: regionFilter,
}),
getAllProviders(),
getAllProviderGroups(),
// Independent of the overview fetch above — the backend resolves the
// same "latest scan per filtered provider" rule from these same raw
// filters, so this can run in parallel instead of waterfalling behind
// the overview response just to read its resolved scan_ids.
getLatestCrossProviderCompliancePdf({
complianceId,
providerTypes: providerTypeFilter,
providerIds: providerIdFilter,
providerGroups: providerGroupsFilter,
}),
]);
if (!crossProviderResponse || "redirectTo" in crossProviderResponse) {
return (
@@ -124,9 +153,27 @@ export default async function ComplianceDetail({
}
const headerTitle = crossProviderData.attributes.name || crossProviderTitle;
const compatibleProviderTypes = new Set(
crossProviderData.attributes.compatible_providers,
);
const compatibleProviders = (providersData?.data || []).filter((provider) =>
compatibleProviderTypes.has(provider.attributes.provider),
);
return (
<ContentLayout title={headerTitle}>
<CrossProviderDetail attributes={crossProviderData.attributes} />
<CrossProviderDetail
attributes={crossProviderData.attributes}
providers={compatibleProviders}
providerGroups={providerGroupsData?.data || []}
providerTypeFilter={providerTypeFilter}
providerIdFilter={providerIdFilter}
providerGroupsFilter={providerGroupsFilter}
latestPdfReport={
"available" in latestPdfReport && latestPdfReport.available
? latestPdfReport
: null
}
/>
</ContentLayout>
);
}
+5
View File
@@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import { CrossProviderPdfWatcher } from "@/components/compliance/cross-provider/cross-provider-pdf-watcher";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
@@ -105,6 +106,10 @@ export default async function RootLayout({
</>
)}
<MainLayout>{children}</MainLayout>
{/* Single mount point so cross-provider PDF generations keep being
polled — and their "ready" toast still fires — after the user
navigates away from the page they were generated on. */}
<CrossProviderPdfWatcher />
<Toaster />
</Providers>
</body>
@@ -36,4 +36,81 @@ describe("client accordion content", () => {
it("renders a per-provider breakdown table when providers are exposed", () => {
expect(source).toContain("Per-Provider Breakdown");
});
describe("scope-aware cache invalidation", () => {
// Regression coverage for a real bug: switching the provider-type /
// provider-id / region filters on the cross-provider compliance page
// re-renders an already-expanded requirement with a *different* scan
// scope, but page/pageSize/sort/mutedFilter can stay identical — so a
// guard keyed only on those would wrongly treat the new scope as
// "already loaded" and keep showing findings from the filtered-out
// provider/region.
it("derives the fetch scope from scan/check identity AND region, not just pagination/sort/mute", () => {
expect(source).toMatch(
/const scopeSignature = isCrossProvider\s*\n\s*\?\s*JSON\.stringify\(\{\s*scanIdsByProvider,\s*checkIdsByProvider,\s*region\s*\}\)/,
);
// The per-scan branch's signature must also fold in ``region`` — a
// requirement with the same scanId/check_ids but a different region
// filter is a different fetch.
expect(source).toMatch(
/:\s*`\$\{scanId\}\|\$\{\(requirement\.check_ids \|\| \[\]\)\.join\(","\)\}\|\$\{region\}`/,
);
});
it("includes the scope signature in the already-loaded skip guard", () => {
const guardMatch = source.match(
/if \(\s*\n([\s\S]*?)\n\s*\) \{\s*\n\s*return;/,
);
expect(guardMatch, "expected to find the skip-fetch guard").not.toBe(
null,
);
const guardBody = guardMatch![1];
expect(guardBody).toContain("loadedScopeRef.current === scopeSignature");
// The pre-existing guard members must still be present — this test
// should fail if a refactor drops one of them, not just if scope is
// missing.
expect(guardBody).toContain("loadedPageRef.current === pageNumber");
expect(guardBody).toContain("loadedMutedRef.current === mutedFilter");
});
it("only marks the scope as loaded once the fetch actually commits, for both branches", () => {
// Marking it eagerly (before the fetch resolves) would let a
// superseded in-flight request's cleanup permanently strand the
// component at ``findings === null`` — see the neighboring comment
// in source for the full race explanation.
const commitPattern = /loadedScopeRef\.current = scopeSignature;/g;
const commitIndices = Array.from(source.matchAll(commitPattern)).map(
(match) => match.index!,
);
expect(commitIndices).toHaveLength(2); // cross-provider branch + per-scan branch
for (const index of commitIndices) {
// A cancellation check must precede the commit nearby, and nothing
// awaits in between — i.e. the ref is only written synchronously
// after the fetch has already resolved and the in-flight request
// wasn't superseded by a newer one.
const window = source.slice(Math.max(0, index - 250), index);
const guardIndex = window.search(/if \(cancelled\) return;/);
expect(
guardIndex,
"expected a nearby cancellation check",
).toBeGreaterThanOrEqual(0);
expect(window.slice(guardIndex)).not.toContain("await ");
}
});
it("re-runs the effect when the scope signature changes", () => {
// scopeSignature is derived from requirement/scanIdsByProvider/
// checkIdsByProvider/region, which are already effect dependencies,
// so this mostly guards against someone deleting the dependency
// outright under the (wrong) assumption it's redundant.
const depsMatch = source.match(/\}, \[([\s\S]*?)\]\);/);
expect(
depsMatch,
"expected to find the effect's dependency array",
).not.toBe(null);
expect(depsMatch![1]).toContain("scopeSignature");
});
});
});
@@ -9,6 +9,7 @@ import {
getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import { Alert, AlertDescription } from "@/components/shadcn";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
@@ -16,10 +17,7 @@ import { StatusFindingBadge } from "@/components/ui/table/status-finding-badge";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { getProviderLabel } from "@/lib/providers/provider-display";
import {
CrossProviderRequirement,
Requirement,
@@ -64,6 +62,7 @@ export const ClientAccordionContent = ({
const loadedPageSizeRef = useRef<string | null>(null);
const loadedSortRef = useRef<string | null>(null);
const loadedMutedRef = useRef<string | null>(null);
const loadedScopeRef = useRef<string | null>(null);
const isExpandedRef = useRef(false);
const region = searchParams.get("filter[region__in]") || "";
// Respect the user's muted preference from the URL; default to EXCLUDE
@@ -80,6 +79,15 @@ export const ClientAccordionContent = ({
const providersBreakdown = xprov.providers;
const isCrossProvider =
!!scanIdsByProvider && Object.keys(scanIdsByProvider).length > 0;
// Identifies *what* findings this requirement should show — which scans,
// checks and region it's scoped to. Provider-type/account/region filters
// on the page narrow the fetch server-side without necessarily changing
// page/sort/mute, so those alone aren't enough to tell a stale fetch from
// a fresh one: an already-expanded row would otherwise keep showing
// findings from providers or regions the user just filtered out.
const scopeSignature = isCrossProvider
? JSON.stringify({ scanIdsByProvider, checkIdsByProvider, region })
: `${scanId}|${(requirement.check_ids || []).join(",")}|${region}`;
useEffect(() => {
// Guard against a slower earlier request resolving after a newer one and
@@ -93,16 +101,20 @@ export const ClientAccordionContent = ({
loadedPageSizeRef.current === pageSize &&
loadedSortRef.current === sort &&
loadedMutedRef.current === mutedFilter &&
loadedScopeRef.current === scopeSignature &&
isExpandedRef.current
) {
return;
}
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
isExpandedRef.current = true;
// Mark "loaded" for these exact params only once the fetch actually
// commits (below, right before each ``setFindings``) — not here. If a
// dependency changes (e.g. a sibling re-render gives ``requirement`` a
// new identity) while this fetch is in flight, the effect cleanup
// flips ``cancelled`` and the commit is skipped; marking the refs
// upfront would make the *next* effect run see "already loaded" and
// skip re-fetching too, permanently stranding the component with
// ``findings`` stuck at ``null``.
try {
const encodedSort = sort.replace(/^\+/, "");
@@ -182,6 +194,12 @@ export const ClientAccordionContent = ({
},
};
if (cancelled) return;
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
loadedScopeRef.current = scopeSignature;
isExpandedRef.current = true;
setFindings(merged);
return;
}
@@ -202,6 +220,12 @@ export const ClientAccordionContent = ({
});
if (cancelled) return;
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
loadedScopeRef.current = scopeSignature;
isExpandedRef.current = true;
setFindings(findingsData);
} catch (error) {
console.error("Error loading findings:", error);
@@ -221,6 +245,7 @@ export const ClientAccordionContent = ({
sort,
region,
mutedFilter,
scopeSignature,
disableFindings,
isCrossProvider,
scanIdsByProvider,
@@ -413,7 +438,6 @@ export const ClientAccordionContent = ({
<div className="flex flex-col gap-3 px-3 pb-2">
{checkIdsByProviderEntries.map(([providerKey, ids], idx) => {
const label = getProviderLabel(providerKey);
const Badge = getProviderBadge(providerKey);
return (
<div
key={providerKey}
@@ -424,24 +448,32 @@ export const ClientAccordionContent = ({
}`}
>
<div className="flex items-center gap-2">
{Badge ? <Badge size={16} /> : null}
<span className="text-text-default text-xs font-semibold tracking-wider uppercase">
<ProviderBadgeIcon providerKey={providerKey} size={16} />
<span className="text-text-neutral-primary text-xs font-semibold">
{label}
</span>
<span className="text-text-neutral-secondary text-xs">
{ids.length} {ids.length === 1 ? "check" : "checks"}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pl-6">
{/* Soft filled chips, no border: bordered pills read as a "wall
of boxes" once there are 5-10 of them, but bare text (no
fill at all) reads as unstyled/unfinished and a fixed-width
grid left ragged gaps for the shorter ids. A tinted
background gives each check enough visual weight to look
intentional while staying quiet — flex-wrap sizes every
chip to its own content so nothing is cramped or stranded
in dead space regardless of how long the check id is. */}
<ul className="flex flex-wrap gap-1.5 pl-6">
{ids.map((id) => (
<span
<li
key={id}
className="border-border-neutral-secondary inline-block rounded border bg-gray-50 px-2 py-0.5 font-mono text-[11px] text-gray-700 dark:bg-gray-900/50 dark:text-gray-200"
className="bg-default-100 hover:bg-default-200 text-text-neutral-secondary dark:bg-default-100/10 dark:hover:bg-default-100/20 rounded-md px-2 py-1 font-mono text-[11px] transition-colors"
>
{id}
</span>
</li>
))}
</div>
</ul>
</div>
);
})}
@@ -1,10 +1,8 @@
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { getProviderLabel } from "@/lib/providers/provider-display";
import { RequirementStatus } from "@/types/compliance";
interface ComplianceAccordionRequirementTitleProps {
@@ -44,14 +42,17 @@ export const ComplianceAccordionRequirementTitle = ({
{type}
</span>
)}
<span className="truncate">{name}</span>
<span className="text-text-neutral-primary truncate">{name}</span>
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
</div>
<div className="flex flex-wrap items-center gap-2">
{/* The accordion trigger this title lives in applies ``hover:underline``
to all its text — fine for the plain requirement name, but it paints
straight through the provider chips and status pill below. Opt this
whole cluster out so hovering the row never underlines a badge. */}
<div className="flex flex-wrap items-center gap-2 no-underline">
{providerEntries.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{providerEntries.map(([providerKey, providerStatus]) => {
const Badge = getProviderBadge(providerKey);
const label = getProviderLabel(providerKey);
return (
<span
@@ -59,10 +60,8 @@ export const ComplianceAccordionRequirementTitle = ({
className="border-border-neutral-secondary inline-flex items-center gap-1 rounded border px-1.5 py-0.5"
title={`${label}: ${providerStatus}`}
>
{Badge ? <Badge size={12} /> : null}
<span className="text-[10px] font-semibold uppercase">
{label}
</span>
<ProviderBadgeIcon providerKey={providerKey} size={12} />
<span className="text-[10px] font-semibold">{label}</span>
<span
className={`size-1.5 rounded-full ${
STATUS_DOT_CLASS_BY_STATUS[providerStatus] ??
@@ -75,7 +74,22 @@ export const ComplianceAccordionRequirementTitle = ({
})}
</div>
)}
<StatusFindingBadge status={status} />
{providerEntries.length > 0 ? (
// Fixed width, not just the badge's own ``min-w`` — "Manual" is
// visibly wider than "Pass"/"Fail", and this cluster is right-
// justified, so a variable-width badge shifts the provider chips
// left/right depending on which status happens to render. Locking
// the slot width keeps the chips at the same x across every row.
// Scoped to cross-provider mode only (``providerEntries.length``)
// — every other framework has no chips to keep aligned, so it
// keeps the badge at its natural width instead of losing space
// to an empty fixed slot.
<div className="flex w-[72px] shrink-0 justify-end">
<StatusFindingBadge status={status} />
</div>
) : (
<StatusFindingBadge status={status} />
)}
</div>
</div>
);
@@ -39,4 +39,54 @@ describe("CompliancePageTabs", () => {
expect(tabsSource).toContain("perScanContent");
expect(tabsSource).toContain("crossProviderContent");
});
describe("Cross-Provider tab trigger structure", () => {
// The active-tab underline sizes itself off ``:not(:first-child)`` /
// ``:last-child`` selectors keyed on the trigger's OWN position among
// its siblings (see components/shadcn/tabs/tabs.tsx). Wrapping the
// trigger in an extra element breaks that and silently drops both the
// inter-tab padding AND the underline's left inset, which previously
// produced a visibly misaligned (then, when only half-fixed, a
// vanished) active indicator on this tab alone. The fix: only pay that
// wrapper tax on the OSS/disabled branch where it's structurally
// required (tooltip-on-disabled + upsell badge) — the common, enabled
// case renders a plain trigger exactly like every other tab in the app
// (see app/(prowler)/providers/provider-page-tabs.tsx) and needs no
// compensating classes at all.
const ternaryMatch = tabsSource.match(
/\{crossProviderEnabled \? \(([\s\S]*?)\) : \(([\s\S]*?)\)\}/,
);
it("finds the enabled/disabled branches", () => {
expect(
ternaryMatch,
"expected to find the crossProviderEnabled ternary",
).not.toBe(null);
});
const enabledBranch = ternaryMatch?.[1] ?? "";
const disabledBranch = ternaryMatch?.[2] ?? "";
it("renders a plain, unwrapped trigger when the tab is enabled — same shape as every other tab", () => {
expect(enabledBranch).toContain("<TabsTrigger");
expect(enabledBranch).not.toContain("<span");
// No manual padding/underline-inset compensation needed: this branch
// is a direct child of TabsList, so the shared component's own
// ``:not(:first-child)``/``:last-child`` rules apply automatically.
expect(enabledBranch).not.toContain("className=");
});
it("keeps the compensating padding and underline inset on the disabled/OSS branch", () => {
expect(disabledBranch).toContain("<span");
const triggerMatch = disabledBranch.match(
/<TabsTrigger[\s\S]*?className="([^"]*)"/,
);
expect(
triggerMatch,
"expected the disabled TabsTrigger to carry a className",
).not.toBe(null);
expect(triggerMatch![1]).toContain("pl-4");
expect(triggerMatch![1]).toContain("after:left-4");
});
});
});
@@ -100,25 +100,42 @@ export const CompliancePageTabs = ({
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{/* The wrapper span (not the disabled TabsTrigger) is the hover
target so the tooltip still fires when the tab is disabled in
OSS. The span makes the trigger the first child of its own
element, which drops the TabsList's inter-tab ``pl-4`` and
keeps a trailing ``border-r``; restore the left padding so the
label clears the Per-Scan divider and drop the divider so the
cloud badge isn't crammed against it. */}
<span className="inline-flex items-center">
<TabsTrigger
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
disabled={!crossProviderEnabled}
className="border-r-0 pl-4"
>
{crossProviderEnabled ? (
// Plain trigger, direct child of TabsList — same shape as
// "Per Scan" and as ProviderPageTabs' tabs. shadcn's active
// underline sizes itself off ``:not(:first-child)`` /
// ``:last-child`` selectors on the trigger's OWN position
// among its siblings; wrapping it in an extra element (as
// the disabled branch below has to, for the tooltip-on-
// disabled + badge requirements) breaks that and requires
// hand-restoring every dropped padding/inset rule. Skipping
// the wrapper whenever it isn't structurally necessary
// keeps the common (enabled) case correct for free.
<TabsTrigger value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}>
Cross-Provider
</TabsTrigger>
{!crossProviderEnabled && (
) : (
// Disabled in OSS: the wrapper span (not the disabled
// TabsTrigger) is the hover target so the tooltip still
// fires, and it also hosts the upsell badge next to the
// label. That makes the trigger the first/only child of the
// span instead of TabsList, dropping the
// ``[&:not(:first-child)]`` padding and the matching
// ``[&:not(:first-child)]:after:left-4`` underline inset —
// restore both by hand or the (invisible, disabled) active
// underline would sit misaligned if this tab were ever
// reachable.
<span className="inline-flex items-center">
<TabsTrigger
value={COMPLIANCE_PAGE_TAB.CROSS_PROVIDER}
disabled
className="border-r-0 pl-4 after:left-4"
>
Cross-Provider
</TabsTrigger>
<CloudFeatureBadgeLink size="sm" className="ml-3" />
)}
</span>
</span>
)}
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{crossProviderEnabled
@@ -34,4 +34,30 @@ describe("CrossProviderCard", () => {
expect(source).toContain("compatibleProviders");
expect(source).toContain("ProviderChip");
});
it("renders each provider as a compact icon, not a full uppercase-label pill", () => {
// Regression guard: frameworks can declare 15+ compatible providers (CIS
// Controls 8.1 has 18) — one full-width uppercase-label pill per
// provider used to wrap across 4-5 rows, dwarfing every other card in
// the same grid. The fix reuses the same compact icon-square language
// as the per-domain provider heatmap on the detail page
// (CrossProviderDomainTitle) instead of spelling out each provider name.
expect(source).toContain("ProviderBadgeIcon");
// ``{providerKey}`` as JSX *text content* (old pill label) vs. as a prop
// value (``providerKey={providerKey}``, still expected) — only the
// former is the regression this guards against.
expect(source).not.toMatch(/>\s*\{providerKey\}\s*</);
});
it("links the info button next to the title to the framework's Prowler Hub page", () => {
expect(source).toContain("getProwlerHubComplianceUrl(complianceId)");
expect(source).toContain('target="_blank"');
});
it("stops the info button's click from also triggering the card's own drill-down navigation", () => {
// Regression guard: the whole card is a click target (onClick={navigateToDetail}).
// Without stopPropagation, opening the Hub link would also navigate the
// card to the detail page underneath the new tab.
expect(source).toContain("e.stopPropagation()");
});
});
@@ -1,10 +1,13 @@
"use client";
import { Check, Circle } from "lucide-react";
import { Info } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import { Button } from "@/components/shadcn/button/button";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { Progress } from "@/components/shadcn/progress";
import {
@@ -12,10 +15,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
import {
getScoreIndicatorClass,
type ScoreColorVariant,
} from "@/lib/compliance/score-utils";
import { getProviderLabel } from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
export interface CrossProviderCardProps {
@@ -52,25 +57,31 @@ interface ProviderChipProps {
active: boolean;
}
/**
* Icon-only chip, not a text pill: universal frameworks can declare 15+
* compatible providers (CIS Controls 8.1 alone has 18), and one full-width
* uppercase-label pill per provider used to wrap across 4-5 rows on a card
* that also has to fit a title, score bar and description — every other
* card in the same grid stayed a fraction of that height. Same compact
* heatmap-cell language as the per-domain provider row on the detail page
* (``CrossProviderDomainTitle``): one row of small icon squares regardless
* of provider count, with the full name available on hover/focus.
*/
const ProviderChip = ({ providerKey, active }: ProviderChipProps) => {
const Icon = active ? Check : Circle;
const label = getProviderLabel(providerKey);
const tooltip = active ? `${label}: scan available` : `${label}: no scan yet`;
return (
<span
title={tooltip}
aria-label={tooltip}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5",
"text-[10px] font-semibold tracking-wide uppercase",
"flex size-6 shrink-0 items-center justify-center rounded-md border",
active
? "border-bg-pass/40 bg-bg-pass/10 text-bg-pass"
: "border-border-neutral-secondary text-text-neutral-secondary",
? "border-bg-pass/40 bg-bg-pass/10"
: "border-border-neutral-secondary opacity-40",
)}
title={
active
? `${providerKey.toUpperCase()}: scan available`
: `${providerKey.toUpperCase()}: no scan yet`
}
>
<Icon className="size-3 shrink-0" strokeWidth={active ? 3 : 2} />
{providerKey}
<ProviderBadgeIcon providerKey={providerKey} size={14} />
</span>
);
};
@@ -146,18 +157,40 @@ export const CrossProviderCard: React.FC<CrossProviderCardProps> = ({
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="text-small truncate leading-5 font-bold">
<div className="flex min-w-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="text-small min-w-0 truncate leading-5 font-bold">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
</TooltipTrigger>
<TooltipContent>
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
</TooltipTrigger>
<TooltipContent>
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</TooltipContent>
</Tooltip>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="bare" size="icon-xs" asChild>
<Link
href={getProwlerHubComplianceUrl(complianceId)}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
aria-label="View on Prowler Hub"
// The card's own onClick navigates to the detail
// page — without this, clicking the info button
// would open the Hub tab AND drill into the card.
onClick={(e) => e.stopPropagation()}
>
<Info className="size-3.5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View on Prowler Hub</TooltipContent>
</Tooltip>
</div>
{description && (
<small className="text-text-neutral-secondary mt-0.5 line-clamp-2 text-xs">
{description}
@@ -2,14 +2,28 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { CrossProviderExplorerCard } from "./cross-provider-explorer-card";
import { CrossProviderFilters } from "./cross-provider-filters";
import { CrossProviderHeader } from "./cross-provider-header";
interface CrossProviderDetailClientProps {
attributes: CrossProviderComplianceOverviewAttributes;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` values from the URL — threaded down to
* the "Generate PDF" button so it respects the same narrowing as this
* already-filtered ``attributes`` payload. */
providerTypeFilter?: string;
providerIdFilter?: string;
providerGroupsFilter?: string;
latestPdfReport: LatestCrossProviderPdfReport | null;
}
/**
@@ -33,6 +47,12 @@ interface CrossProviderDetailClientProps {
*/
export const CrossProviderDetailClient = ({
attributes,
providers,
providerGroups,
providerTypeFilter,
providerIdFilter,
providerGroupsFilter,
latestPdfReport,
}: CrossProviderDetailClientProps) => {
const insights = useMemo(
() => computeCrossProviderInsights(attributes),
@@ -74,11 +94,17 @@ export const CrossProviderDetailClient = ({
return (
<div className="flex flex-col gap-6">
<CrossProviderFilters
providers={providers}
providerGroups={providerGroups}
/>
<CrossProviderHeader
framework={attributes.framework}
name={attributes.name}
version={attributes.version}
description={attributes.description}
complianceId={attributes.compliance_id}
insights={insights}
onDomainSelect={handleDomainSelect}
/>
@@ -88,6 +114,10 @@ export const CrossProviderDetailClient = ({
attributes={attributes}
insights={insights}
forcedExpandedSectionKey={forcedExpandedSectionKey}
providerTypeFilter={providerTypeFilter}
providerIdFilter={providerIdFilter}
providerGroupsFilter={providerGroupsFilter}
latestPdfReport={latestPdfReport}
/>
</div>
</div>
@@ -1,9 +1,31 @@
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { CrossProviderDetailClient } from "./cross-provider-detail-client";
interface CrossProviderDetailProps {
attributes: CrossProviderComplianceOverviewAttributes;
/** Provider accounts of a type compatible with this framework (not
* filtered by connection status) — feeds the provider type / account
* filter controls. */
providers: ProviderProps[];
/** Every provider group in the tenant (not narrowed by framework
* compatibility — groups can span multiple provider types, same as
* every other provider-group filter in the app). */
providerGroups: ProviderGroup[];
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` values from the URL — threaded down to
* the "Generate PDF" button. */
providerTypeFilter?: string;
providerIdFilter?: string;
providerGroupsFilter?: string;
/** A previously-generated PDF matching these exact filters, resolved
* server-side alongside ``attributes`` — ``null`` means none exists yet
* (or it went stale) for the current filters, so the "Generate PDF"
* button shows "Generate" instead of "Download". */
latestPdfReport: LatestCrossProviderPdfReport | null;
}
/**
@@ -17,6 +39,22 @@ interface CrossProviderDetailProps {
*/
export const CrossProviderDetail = ({
attributes,
providers,
providerGroups,
providerTypeFilter,
providerIdFilter,
providerGroupsFilter,
latestPdfReport,
}: CrossProviderDetailProps) => {
return <CrossProviderDetailClient attributes={attributes} />;
return (
<CrossProviderDetailClient
attributes={attributes}
providers={providers}
providerGroups={providerGroups}
providerTypeFilter={providerTypeFilter}
providerIdFilter={providerIdFilter}
providerGroupsFilter={providerGroupsFilter}
latestPdfReport={latestPdfReport}
/>
);
};
@@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { DomainStats } from "@/lib/compliance/cross-provider-insights";
import { CrossProviderDomainTitle } from "./cross-provider-domain-title";
const makeStats = (overrides: Partial<DomainStats> = {}): DomainStats => ({
name: "1. Inventory and Control of Enterprise Assets",
total: 5,
pass: 1,
fail: 2,
manual: 2,
byProvider: {},
...overrides,
});
describe("CrossProviderDomainTitle", () => {
it("reserves a fixed width for the counts column regardless of digit count", () => {
// Regression guard: the name column is flex-1 (grows to fill whatever's
// left on the line) — if the counts column were only ``shrink-0``
// instead of a fixed width, a row with double/triple-digit counts would
// shrink the name column by the difference, shifting the heatmap that
// follows it to a different x than on rows with single-digit counts.
const { container } = render(
<CrossProviderDomainTitle
name="Single-digit counts"
stats={makeStats({ pass: 1, fail: 2, manual: 2 })}
providers={["aws", "azure"]}
/>,
);
const countsContainer = screen.getByText("1").closest("div");
expect(countsContainer).toHaveClass("w-[104px]");
expect(container).toBeTruthy();
});
it("renders the same fixed-width class for triple-digit counts", () => {
render(
<CrossProviderDomainTitle
name="Triple-digit counts"
stats={makeStats({ total: 153, pass: 100, fail: 40, manual: 13 })}
providers={["aws", "azure"]}
/>,
);
const countsContainer = screen.getByText("100").closest("div");
expect(countsContainer).toHaveClass("w-[104px]");
});
it("renders one heatmap cell per provider with the rolled-up status in its accessible name", () => {
render(
<CrossProviderDomainTitle
name="Provider heatmap"
stats={makeStats({ byProvider: { aws: "PASS", azure: "FAIL" } })}
providers={["aws", "azure", "gcp"]}
/>,
);
expect(screen.getByLabelText(/AWS: PASS/)).toBeInTheDocument();
expect(screen.getByLabelText(/Azure: FAIL/)).toBeInTheDocument();
// gcp has no entry in byProvider — falls back to "no scan".
expect(screen.getByLabelText(/Google Cloud.*no scan/)).toBeInTheDocument();
});
it("shows the pass/fail/manual counts", () => {
render(
<CrossProviderDomainTitle
name="Counts"
stats={makeStats({ pass: 7, fail: 3, manual: 1 })}
providers={["aws"]}
/>,
);
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
});
});
@@ -1,13 +1,11 @@
"use client";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import type {
DomainProviderStatus,
DomainStats,
} from "@/lib/compliance/cross-provider-insights";
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { getProviderLabel } from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
const HEATMAP_CELL_BY_STATUS: Record<DomainProviderStatus, string> = {
@@ -27,7 +25,10 @@ const STRIPE_CLASS_BY_DOMINANT = (stats: DomainStats): string => {
interface CrossProviderDomainTitleProps {
name: string;
stats: DomainStats;
compatibleProviders: string[];
/** Providers to render as heatmap columns — the detail view passes the
* SCANNED providers (framework-compatible providers with no scan are
* hidden here; they only appear, dimmed, on the overview). */
providers: string[];
}
/**
@@ -37,9 +38,9 @@ interface CrossProviderDomainTitleProps {
*
* Layout: [colored stripe] [domain name] [heatmap matrix] [stack bar]
* [counts]. The stripe color reflects worst-case status (fail > pass >
* manual > no-row); the heatmap matrix shows one cell per compatible
* provider — dimmed for non-contributing ones — with the precise
* rolled-up status surfaced via the native ``title`` attribute. We
* manual > no-row); the heatmap matrix shows one cell per scanned
* provider, with the precise rolled-up status surfaced via the native
* ``title`` attribute. We
* deliberately avoid Radix Tooltip here: 5 providers × 17 sections =
* 85 cells per page would mount 85 ``TooltipProvider`` + ``Root``
* pairs on initial render.
@@ -47,7 +48,7 @@ interface CrossProviderDomainTitleProps {
export const CrossProviderDomainTitle = ({
name,
stats,
compatibleProviders,
providers,
}: CrossProviderDomainTitleProps) => {
const total = Math.max(stats.total, 1);
const passPct = (stats.pass / total) * 100;
@@ -57,7 +58,10 @@ export const CrossProviderDomainTitle = ({
return (
<div
data-domain-anchor={name}
className="data-[flash=1]:bg-bg-fail/10 flex w-full items-stretch gap-3 transition-colors"
// ``no-underline``: the parent AccordionTrigger applies
// ``hover:underline`` to all its text, which otherwise paints through
// the provider heatmap squares and stacked bar below on hover.
className="data-[flash=1]:bg-bg-fail/10 flex w-full items-stretch gap-3 no-underline transition-colors"
>
<div
className={cn(
@@ -68,7 +72,7 @@ export const CrossProviderDomainTitle = ({
/>
<div className="flex w-full min-w-0 flex-wrap items-center gap-3 py-1">
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-text-default truncate text-sm font-semibold">
<span className="text-text-neutral-primary truncate text-sm font-semibold">
{name}
</span>
<span className="text-text-neutral-secondary mt-0.5 font-mono text-[10px]">
@@ -77,10 +81,9 @@ export const CrossProviderDomainTitle = ({
</div>
<div className="flex shrink-0 items-center gap-1.5">
{compatibleProviders.map((providerKey) => {
{providers.map((providerKey) => {
const status: DomainProviderStatus =
stats.byProvider[providerKey] ?? "NO_ROW";
const Badge = getProviderBadge(providerKey);
const label = getProviderLabel(providerKey);
const tooltip = `${label}: ${status === "NO_ROW" ? "no scan" : status}`;
return (
@@ -93,7 +96,11 @@ export const CrossProviderDomainTitle = ({
HEATMAP_CELL_BY_STATUS[status],
)}
>
{Badge ? <Badge size={12} className="opacity-90" /> : null}
<ProviderBadgeIcon
providerKey={providerKey}
size={12}
className="opacity-90"
/>
</span>
);
})}
@@ -120,7 +127,15 @@ export const CrossProviderDomainTitle = ({
)}
</div>
<div className="text-text-neutral-secondary flex shrink-0 items-center gap-2 font-mono text-[11px] tabular-nums">
{/* Fixed width, not just ``shrink-0``: the name column above is
``flex-1`` (grows to fill whatever's left on the line), so a
pass/fail/manual count that's wider on one row than another
(e.g. double- vs triple-digit) shrinks or grows the name column
by the difference — which shifts every ``shrink-0`` sibling that
comes after it, including the heatmap. Locking this slot's width
keeps the heatmap starting at the same x on every domain row
regardless of how many digits its counts happen to have. */}
<div className="text-text-neutral-secondary flex w-[104px] shrink-0 items-center justify-end gap-2 font-mono text-[11px] tabular-nums">
<span className="text-bg-pass">{stats.pass}</span>
<span aria-hidden="true">·</span>
<span className="text-bg-fail">{stats.fail}</span>
@@ -3,6 +3,7 @@
import { Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import {
@@ -28,6 +29,7 @@ import type {
} from "@/types/compliance";
import { CrossProviderDomainTitle } from "./cross-provider-domain-title";
import { GeneratePdfButton } from "./generate-pdf-button";
interface CrossProviderExplorerCardProps {
attributes: CrossProviderComplianceOverviewAttributes;
@@ -36,6 +38,18 @@ interface CrossProviderExplorerCardProps {
* wants forced-open. Driven by the "Top Failing Domains" panel so a
* click expands and scrolls to the target row. */
forcedExpandedSectionKey?: string | null;
/** Raw ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` values currently applied via
* ``CrossProviderFilters`` — threaded through to ``GeneratePdfButton``
* so the generated PDF respects the same narrowing as the on-screen
* view. */
providerTypeFilter?: string;
providerIdFilter?: string;
providerGroupsFilter?: string;
/** A previously-generated PDF matching the current filters, resolved
* server-side — ``null`` means "Generate PDF" should show, not
* "Download PDF". */
latestPdfReport: LatestCrossProviderPdfReport | null;
}
const STATUS_OPTIONS: Array<{
@@ -68,6 +82,10 @@ export const CrossProviderExplorerCard = ({
attributes,
insights,
forcedExpandedSectionKey,
providerTypeFilter,
providerIdFilter,
providerGroupsFilter,
latestPdfReport,
}: CrossProviderExplorerCardProps) => {
const [searchTerm, setSearchTerm] = useState("");
const [searchKey, setSearchKey] = useState(0);
@@ -204,6 +222,19 @@ export const CrossProviderExplorerCard = ({
right border on intermediate viewports.
*/}
<div className="ml-auto flex max-w-full min-w-0 shrink-0 flex-wrap items-center justify-end gap-2">
<GeneratePdfButton
complianceId={attributes.compliance_id}
scanIds={attributes.scan_ids}
providerTypes={providerTypeFilter}
providerIds={providerIdFilter}
providerGroups={providerGroupsFilter}
latestPdfReport={latestPdfReport}
frameworkLabel={
attributes.name ||
attributes.framework ||
attributes.compliance_id
}
/>
<Button
variant="ghost"
size="sm"
@@ -276,7 +307,7 @@ export const CrossProviderExplorerCard = ({
<CrossProviderDomainTitle
name={section.categoryName}
stats={stats}
compatibleProviders={insights.compatibleProviders}
providers={insights.scannedProviders}
/>
) : (
<span>{section.categoryName}</span>
@@ -0,0 +1,119 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { CrossProviderFilters } from "./cross-provider-filters";
const { providerAccountSelectorsProps, providerGroupSelectorProps } =
vi.hoisted(() => ({
providerAccountSelectorsProps: {
value: undefined as { providers: ProviderProps[] } | undefined,
},
providerGroupSelectorProps: {
value: undefined as { groups: ProviderGroup[] } | undefined,
},
}));
// This wrapper's own job is just "render the shared filter controls when
// there's something to filter" — the controls' own instant/batch/URL
// behaviour is already covered by provider-account-selectors.test.tsx and
// provider-group-selector.test.tsx.
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: (props: { providers: ProviderProps[] }) => {
providerAccountSelectorsProps.value = props;
return <div>Provider account selectors</div>;
},
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: (props: { groups: ProviderGroup[] }) => {
providerGroupSelectorProps.value = props;
return <div>Provider group selector</div>;
},
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: ({ showCount }: { showCount?: boolean }) => (
<button type="button">Clear all{showCount ? " (with count)" : ""}</button>
),
}));
const makeProvider = (id: string, provider: string): ProviderProps =>
({
id,
type: "providers",
attributes: { provider },
}) as unknown as ProviderProps;
const makeGroup = (id: string, name: string): ProviderGroup =>
({
id,
type: "provider-groups",
attributes: { name },
}) as unknown as ProviderGroup;
describe("CrossProviderFilters", () => {
it("renders nothing when there are no compatible providers", () => {
const { container } = render(
<CrossProviderFilters providers={[]} providerGroups={[]} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders the provider account selectors, group selector and clear-filters button when providers exist", () => {
const providers = [makeProvider("azure-1", "azure")];
render(<CrossProviderFilters providers={providers} providerGroups={[]} />);
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
expect(screen.getByText("Provider group selector")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /clear all/i }),
).toBeInTheDocument();
});
it("passes the compatible providers straight through to the account selectors", () => {
const providers = [
makeProvider("azure-1", "azure"),
makeProvider("gcp-1", "gcp"),
];
render(<CrossProviderFilters providers={providers} providerGroups={[]} />);
expect(providerAccountSelectorsProps.value?.providers).toEqual(providers);
});
it("passes every tenant provider group straight through to the group selector", () => {
// Unlike ``providers``, groups are NOT narrowed to the framework's
// compatible provider types — a group can span multiple types, same as
// every other provider-group filter in the app.
const providers = [makeProvider("azure-1", "azure")];
const providerGroups = [makeGroup("group-1", "Production")];
render(
<CrossProviderFilters
providers={providers}
providerGroups={providerGroups}
/>,
);
expect(providerGroupSelectorProps.value?.groups).toEqual(providerGroups);
});
it("shows the active-filter count on the clear button", () => {
// Regression guard: dropping ``showCount`` would silently hide how many
// filters are active, matching how every other filter bar in the app
// surfaces it (findings, resources, scans).
render(
<CrossProviderFilters
providers={[makeProvider("azure-1", "azure")]}
providerGroups={[]}
/>,
);
expect(screen.getByText(/with count/)).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
"use client";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
interface CrossProviderFiltersProps {
/** Every provider account of a type compatible with the current
* framework — already narrowed by the caller to ``compatible_providers``.
* Not filtered by connection status: disconnected accounts still show up
* so the user can see (and filter to) them, same as the account
* selectors elsewhere in the app. */
providers: ProviderProps[];
/** Every provider group in the tenant — not narrowed by framework
* compatibility, same as every other provider-group filter in the app
* (a group can span multiple provider types). */
providerGroups: ProviderGroup[];
}
const FILTER_CONTROL_CLASS =
"w-full sm:max-w-[240px] sm:min-w-[180px] sm:flex-1";
/**
* Provider type / account / group narrowing for the cross-provider
* compliance detail page. Instant mode: picking a value pushes
* ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` straight into the URL, which the server
* component re-reads on navigation and re-runs the aggregation against just
* the selected providers (unlike the search/status controls in
* ``CrossProviderExplorerCard``, which only filter the already-fetched
* payload client-side).
*/
export const CrossProviderFilters = ({
providers,
providerGroups,
}: CrossProviderFiltersProps) => {
if (providers.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-3">
<ProviderAccountSelectors
providers={providers}
providerSelectorClassName={FILTER_CONTROL_CLASS}
accountSelectorClassName={FILTER_CONTROL_CLASS}
/>
<div className={FILTER_CONTROL_CLASS}>
<ProviderGroupSelector groups={providerGroups} />
</div>
<ClearFiltersButton showCount />
</div>
);
};
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("CrossProviderHeader", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "cross-provider-header.tsx");
const source = readFileSync(filePath, "utf8");
it("accepts a complianceId prop to build the Prowler Hub link", () => {
expect(source).toContain("complianceId");
expect(source).toContain("getProwlerHubComplianceUrl(complianceId)");
});
it("opens the Prowler Hub link in a new, safely-referrer-stripped tab", () => {
// ``noopener`` prevents the opened tab from reaching back into this
// window via ``window.opener``; ``noreferrer`` also strips the
// referrer header. Both matter for any ``target="_blank"`` link to an
// external origin.
expect(source).toMatch(
/target="_blank"[\s\S]{0,40}rel="noopener noreferrer"/,
);
});
});
@@ -1,10 +1,14 @@
"use client";
import { SquareArrowOutUpRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Button } from "@/components/shadcn/button/button";
import { Card, CardContent } from "@/components/shadcn/card/card";
import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
import { getProwlerHubComplianceUrl } from "@/lib/compliance/prowler-hub";
import { ProviderCoveragePanel } from "./provider-coverage-panel";
import { ScoreDonut } from "./score-donut";
@@ -19,6 +23,9 @@ interface CrossProviderHeaderProps {
version: string;
/** Long description from the universal JSON. */
description: string;
/** Universal framework id (e.g. ``cis_controls_8.1``) — builds the
* Prowler Hub reference link. */
complianceId: string;
insights: CrossProviderInsights;
/** Click handler when a top-failing-domain entry is selected. */
onDomainSelect?: (domainName: string) => void;
@@ -45,6 +52,7 @@ export const CrossProviderHeader = ({
name,
version,
description,
complianceId,
insights,
onDomainSelect,
}: CrossProviderHeaderProps) => {
@@ -76,6 +84,22 @@ export const CrossProviderHeader = ({
<span className="border-border-neutral-secondary text-text-neutral-secondary rounded border px-1.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase">
Universal
</span>
<Button
variant="link"
size="link-xs"
className="shrink-0 whitespace-nowrap"
asChild
>
<Link
href={getProwlerHubComplianceUrl(complianceId)}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
View on Prowler Hub
<SquareArrowOutUpRight className="size-3" />
</Link>
</Button>
</div>
{description && (
<p className="text-text-neutral-secondary mt-1 line-clamp-2 text-xs">
@@ -0,0 +1,158 @@
import { cleanup, render } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
import { CrossProviderPdfWatcher } from "./cross-provider-pdf-watcher";
const { getTaskMock, toastMock } = vi.hoisted(() => ({
getTaskMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/task", () => ({
getTask: getTaskMock,
}));
vi.mock("@/components/ui/toast", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
toast: toastMock,
}));
vi.mock("next/link", () => ({
default: ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => <a href={href}>{children}</a>,
}));
const POLL_INTERVAL_MS = 3000;
// Reset the (module-level) store between tests so tracked generations from
// one test never leak into the next.
const resetStore = () => useCrossProviderPdfStore.setState({ generations: {} });
describe("CrossProviderPdfWatcher", () => {
beforeEach(() => {
vi.useFakeTimers();
getTaskMock.mockReset();
toastMock.mockReset();
resetStore();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
resetStore();
});
it("fires the ready toast for a tracked generation even with no button mounted", async () => {
getTaskMock.mockResolvedValue({
data: { attributes: { state: "completed" } },
});
// The button is NOT rendered here — only the app-wide watcher. This is
// exactly the "user navigated away from the generating page" case: the
// notification must still arrive.
render(<CrossProviderPdfWatcher />);
useCrossProviderPdfStore.getState().trackGeneration({
taskId: "task-123",
signature: "csa_ccm_4.0|scan-1|||",
reportUrl: "/compliance/csa_ccm_4.0?filter=x",
});
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(getTaskMock).toHaveBeenCalledWith("task-123");
expect(toastMock).toHaveBeenCalledTimes(1);
const payload = toastMock.mock.calls[0]?.[0];
expect(payload.title).toBe("PDF report ready");
// The action links back to the page the report was generated from.
expect(payload.action.props.children.props.href).toBe(
"/compliance/csa_ccm_4.0?filter=x",
);
// Completion is recorded so a still-mounted button can flip to Download.
expect(
useCrossProviderPdfStore.getState().generations["task-123"].status,
).toBe("completed");
});
it("keeps polling while the task is still running and does not toast", async () => {
getTaskMock.mockResolvedValue({
data: { attributes: { state: "executing" } },
});
render(<CrossProviderPdfWatcher />);
useCrossProviderPdfStore.getState().trackGeneration({
taskId: "task-run",
signature: "sig",
reportUrl: "/x",
});
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
expect(getTaskMock).toHaveBeenCalled();
expect(toastMock).not.toHaveBeenCalled();
expect(
useCrossProviderPdfStore.getState().generations["task-run"].status,
).toBe("running");
});
it("tolerates transient getTask errors and gives up only after several", async () => {
getTaskMock.mockResolvedValue({ error: "boom" });
render(<CrossProviderPdfWatcher />);
useCrossProviderPdfStore.getState().trackGeneration({
taskId: "task-err",
signature: "sig",
reportUrl: "/x",
});
// Two consecutive errors: still below the threshold — no failure toast.
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
expect(toastMock).not.toHaveBeenCalled();
expect(
useCrossProviderPdfStore.getState().generations["task-err"].status,
).toBe("running");
// Third consecutive error crosses the threshold — fail and stop.
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(toastMock).toHaveBeenCalledTimes(1);
expect(toastMock.mock.calls[0]?.[0].variant).toBe("destructive");
expect(
useCrossProviderPdfStore.getState().generations["task-err"].status,
).toBe("failed");
});
it("surfaces a terminal failure state with the task's error message", async () => {
getTaskMock.mockResolvedValue({
data: {
attributes: { state: "failed", result: { error: "no scans" } },
},
});
render(<CrossProviderPdfWatcher />);
useCrossProviderPdfStore.getState().trackGeneration({
taskId: "task-fail",
signature: "sig",
reportUrl: "/x",
});
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
expect(toastMock).toHaveBeenCalledTimes(1);
const payload = toastMock.mock.calls[0]?.[0];
expect(payload.variant).toBe("destructive");
expect(payload.description).toContain("no scans");
expect(
useCrossProviderPdfStore.getState().generations["task-fail"].status,
).toBe("failed");
});
});
@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useRef } from "react";
import { getTask } from "@/actions/task";
import { toast, ToastAction } from "@/components/ui/toast";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
import type { GetTaskResponse, TaskState } from "@/types/tasks";
type PdfTaskResult = { error?: string };
const POLL_INTERVAL_MS = 3000;
// Allowlist of "still running" states rather than hardcoding "done" states:
// treating anything NOT in this set as terminal means an unhandled state
// (e.g. "cancelled", or a future state we don't know about yet) stops
// polling and surfaces an error instead of spinning forever.
const RUNNING_STATES = new Set<TaskState>([
"available",
"scheduled",
"executing",
]);
// Transient getTask failures (network blip, one 5xx) must not abort a
// generation that is still running server-side — give up only after several
// consecutive failures.
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
/**
* Single, app-wide poller for cross-provider PDF generations.
*
* Mounted once in the ``(prowler)`` layout so it survives navigation between
* views. When the user clicks "Generate PDF" the button registers the task in
* ``useCrossProviderPdfStore`` and unmounts freely; this watcher keeps polling
* every tracked generation and fires the "ready"/"failed" toast when each one
* settles — so the notification arrives even if the user has since switched to
* the overview or another framework. The toast's action links back to the
* exact page the report was generated from (where the button now offers
* "Download PDF").
*/
export const CrossProviderPdfWatcher = () => {
const generations = useCrossProviderPdfStore((state) => state.generations);
const markCompleted = useCrossProviderPdfStore(
(state) => state.markCompleted,
);
const markFailed = useCrossProviderPdfStore((state) => state.markFailed);
// Latest generations snapshot for the interval callback to read without
// being re-created (and re-scheduling the interval) on every store change.
const generationsRef = useRef(generations);
generationsRef.current = generations;
// Per-task bookkeeping (in-flight tick + consecutive error count) that must
// NOT trigger React re-renders — a plain ref keyed by task id.
const pollStateRef = useRef<Map<string, { busy: boolean; errors: number }>>(
new Map(),
);
// A single mount-time poller (setup on mount, cleanup on unmount). It reads
// the live generations snapshot from ``generationsRef`` and the store
// actions (``markCompleted``/``markFailed``) are stable Zustand references,
// so capturing them at mount is safe — no dependency array needed.
useMountEffect(() => {
const interval = setInterval(() => {
const running = Object.values(generationsRef.current).filter(
(generation) => generation.status === "running",
);
// Prune bookkeeping for tasks that are no longer running so the map
// doesn't grow unbounded across a long session.
const runningIds = new Set(
running.map((generation) => generation.taskId),
);
for (const trackedId of Array.from(pollStateRef.current.keys())) {
if (!runningIds.has(trackedId)) pollStateRef.current.delete(trackedId);
}
for (const generation of running) {
let pollState = pollStateRef.current.get(generation.taskId);
if (!pollState) {
pollState = { busy: false, errors: 0 };
pollStateRef.current.set(generation.taskId, pollState);
}
// Skip a task whose previous poll (a whole-task fetch) is still in
// flight — otherwise a slow tick could double-fire the ready toast.
if (pollState.busy) continue;
pollState.busy = true;
const state = pollState;
void (async () => {
try {
const task = (await getTask(
generation.taskId,
)) as GetTaskResponse<PdfTaskResult>;
if ("error" in task) {
state.errors += 1;
if (state.errors < MAX_CONSECUTIVE_POLL_ERRORS) return;
markFailed(generation.taskId);
toast({
variant: "destructive",
title: "PDF generation failed",
description: task.error,
});
return;
}
state.errors = 0;
const taskState = task.data?.attributes?.state;
if (taskState && RUNNING_STATES.has(taskState)) return;
if (taskState === "completed") {
markCompleted(generation.taskId);
toast({
title: "PDF report ready",
description:
"Your combined compliance PDF has been generated and is ready to download.",
action: generation.reportUrl ? (
<ToastAction altText="Go to the report" asChild>
<Link href={generation.reportUrl}>View report</Link>
</ToastAction>
) : undefined,
});
return;
}
// Any other terminal state ("failed", "cancelled", or an
// unrecognized one) — stop tracking rather than spin forever.
markFailed(generation.taskId);
toast({
variant: "destructive",
title: "PDF generation failed",
description:
task.data?.attributes?.result?.error ||
`The report generation task did not complete (state: ${taskState ?? "unknown"}).`,
});
} finally {
state.busy = false;
}
})();
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
});
return null;
};
@@ -0,0 +1,429 @@
"use client";
import { DownloadIcon, FileDown, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
import {
generateCrossProviderCompliancePdf,
getCrossProviderCompliancePdf,
} from "@/actions/compliances";
import { Button } from "@/components/shadcn/button/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/shadcn/dialog";
import { Input } from "@/components/shadcn/input/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { toast } from "@/components/ui/toast";
import { downloadFile } from "@/lib/helper";
import { cn } from "@/lib/utils";
import { useCrossProviderPdfStore } from "@/store/cross-provider-pdf/store";
interface GeneratePdfButtonProps {
/** e.g. "csa_ccm_4.0" — the universal framework currently on screen. */
complianceId: string;
/**
* The exact scan ids backing the overview currently rendered
* (``attributes.scan_ids``), so the PDF matches what the user sees
* instead of re-resolving "latest scan per provider" a second time.
*/
scanIds: string[];
/**
* The same ``filter[provider_type__in]`` / ``filter[provider_id__in]`` /
* ``filter[provider_groups__in]`` values currently applied via
* ``CrossProviderFilters`` (raw comma-separated strings straight from the
* URL search params). ``scanIds`` is already narrowed by these — passing
* them too is defense-in-depth for the edge case where ``scanIds`` ends up
* empty and generation would otherwise fall back to auto-selecting across
* every compatible provider instead of respecting an active filter.
*/
providerTypes?: string;
providerIds?: string;
providerGroups?: string;
/**
* A report already generated for these exact filters, resolved
* server-side alongside the page's data fetch — ``null`` means none
* exists yet (or it went stale because a contributing provider completed
* a new scan since), so the button shows "Generate" instead of
* "Download". Whenever the caller's filters change, a fresh value flows
* in here via a normal prop update (App Router re-runs the server
* component on navigation) and this component resyncs to it — state is
* derived per filter signature, so a report invalidated under the previous
* filters simply stops matching once the signature changes.
*/
latestPdfReport: LatestCrossProviderPdfReport | null;
/** Human-readable framework label (e.g. "CSA Cloud Controls Matrix v4.0")
* used to pre-fill the "name your report" dialog. */
frameworkLabel: string;
className?: string;
}
/** Default report name pre-filled in the dialog: framework + today's date,
* so it's descriptive and unique out of the box while still editable. */
const buildDefaultReportName = (frameworkLabel: string) => {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, "0");
const dd = String(today.getDate()).padStart(2, "0");
return `${frameworkLabel} - ${yyyy}-${mm}-${dd}`;
};
const formatGeneratedAt = (iso?: string) => {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
};
/**
* "Generate PDF" / "Download PDF" button for the cross-provider compliance
* view. Three states:
*
* - No report for the current filters ("idle"): "Generate PDF". Triggers
* ad-hoc generation, however long it takes (the backend job has no fixed
* time budget). The button does NOT poll itself: it registers the task in
* ``useCrossProviderPdfStore`` and the app-wide ``CrossProviderPdfWatcher``
* (mounted once in the layout) polls it to completion. That's what lets
* the "ready" toast still fire after the user navigates away from this
* page — the button unmounts, but the watcher and store persist.
* - Mid-generation: "Generating..." (disabled, spinner) — derived from a
* ``running`` entry in the store whose signature matches this button's.
* - A report exists for the current filters ("available", because the
* server resolved one on load or a store generation for this signature
* just completed): "Download PDF" — downloads immediately, no regen.
*
* Generation does NOT auto-download. When the watcher sees the job finish it
* fires a "ready" toast whose action links back to the page the report was
* generated from (where the button is now "Download PDF"); the user chooses
* when to download.
*
* Changing provider/scan filters is a real navigation (URL search params),
* so the parent re-fetches and this component receives a fresh
* ``latestPdfReport`` prop; state is derived per filter signature, so a
* report generated under the previous filters never lingers as "available"
* after the filters change.
*/
export const GeneratePdfButton = ({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
latestPdfReport,
frameworkLabel,
className,
}: GeneratePdfButtonProps) => {
const [isStarting, setIsStarting] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
// "Name your report" dialog state.
const [nameDialogOpen, setNameDialogOpen] = useState(false);
const [reportName, setReportName] = useState("");
// Server-resolved report that turned out not to be servable when the user
// actually clicked Download (file expired/cleaned up). Without this, the
// stale ``latestPdfReport`` prop would keep the button on "Download PDF"
// forever, every click failing with a toast telling the user to press a
// Generate button that never appears. Tagged with the filter signature it
// was invalidated under, so it's derived away (not reset via an effect)
// the moment the filters change and a fresh ``latestPdfReport`` flows in.
const [invalidated, setInvalidated] = useState<{
signature: string;
taskId: string;
} | null>(null);
const generations = useCrossProviderPdfStore((state) => state.generations);
const trackGeneration = useCrossProviderPdfStore(
(state) => state.trackGeneration,
);
const removeGeneration = useCrossProviderPdfStore(
(state) => state.removeGeneration,
);
// The filter signature this button was rendered for. When it changes
// (the user picked different providers, or navigated to a different
// framework), any report generated under the *previous* signature is no
// longer relevant — state is derived per signature, so it simply stops
// matching and the button falls back to whatever the server resolved for
// the new signature (``latestPdfReport``, already correct because it was
// fetched for these new filters).
const filterSignature = [
complianceId,
scanIds.join(","),
providerTypes,
providerIds,
providerGroups,
].join("|");
// The invalidation only applies to the signature it happened under; once
// the filters change it stops matching and the button falls back to the
// freshly resolved ``latestPdfReport`` — no reset effect needed.
const invalidatedTaskId =
invalidated?.signature === filterSignature ? invalidated.taskId : null;
// Derive this button's view straight from the shared store, matched on the
// signature the generation was started under: a generation started here is
// only "mine" (and its finished report only offered for download) while the
// rendered filters still match the ones it was generated for. A ``running``
// entry takes priority over a ``completed`` one for the same signature —
// regenerating after invalidating a stale report leaves both, and the button
// must show "Generating..." (not the old report's "Download").
const tracked = Object.values(generations);
const runningForSignature = tracked.find(
(generation) =>
generation.signature === filterSignature &&
generation.status === "running",
);
const completedForSignature = tracked.find(
(generation) =>
generation.signature === filterSignature &&
generation.status === "completed" &&
generation.taskId !== invalidatedTaskId,
);
const isGenerating = isStarting || Boolean(runningForSignature);
const storeReport = completedForSignature
? { taskId: completedForSignature.taskId }
: null;
const serverReport =
latestPdfReport && latestPdfReport.taskId !== invalidatedTaskId
? latestPdfReport
: null;
const availableReport: Pick<LatestCrossProviderPdfReport, "taskId"> &
Partial<LatestCrossProviderPdfReport> = storeReport ??
serverReport ?? { taskId: "" };
const hasAvailableReport = Boolean(storeReport ?? serverReport);
// Read the live filter signature from a ref inside the async generate
// handler (refs are exempt from the hook dependency array and always hold
// the current-render value) so the store entry is tagged with the filters
// in effect at click time.
const currentSignatureRef = useRef(filterSignature);
currentSignatureRef.current = filterSignature;
const handleGenerate = useCallback(
async (chosenName: string) => {
if (isGenerating) return;
setIsStarting(true);
const signature = currentSignatureRef.current;
// Snapshot the page URL (path + filters) now, so the watcher's "ready"
// toast can link back here even if the user has navigated elsewhere by
// the time generation finishes.
const reportUrl = window.location.pathname + window.location.search;
toast({
title: "Generating PDF report",
description:
"This may take a while — the report combines every provider's latest scan for the current filters. You'll get a notification when it's ready to download; you can keep browsing in the meantime.",
});
const trimmedName = chosenName.trim();
const result = await generateCrossProviderCompliancePdf({
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
// Empty → server falls back to a unique timestamped default.
reportName: trimmedName.length > 0 ? trimmedName : undefined,
});
setIsStarting(false);
if ("error" in result) {
toast({
variant: "destructive",
title: "Unable to start PDF generation",
description: result.error,
});
return;
}
// Hand off to the app-wide watcher: it polls to completion and fires the
// "ready" toast even if this button unmounts on navigation.
trackGeneration({ taskId: result.taskId, signature, reportUrl });
},
[
complianceId,
scanIds,
providerTypes,
providerIds,
providerGroups,
isGenerating,
trackGeneration,
],
);
// Opening the dialog seeds the input with a descriptive default the user
// can accept or overwrite.
const openNameDialog = useCallback(() => {
setReportName(buildDefaultReportName(frameworkLabel));
setNameDialogOpen(true);
}, [frameworkLabel]);
const confirmGenerate = useCallback(() => {
setNameDialogOpen(false);
void handleGenerate(reportName);
}, [handleGenerate, reportName]);
const handleDownload = useCallback(async () => {
if (!hasAvailableReport || isDownloading || isGenerating) return;
setIsDownloading(true);
toast({
title: "Download Started",
description: "Preparing the combined compliance PDF report.",
});
try {
const result = await getCrossProviderCompliancePdf(
availableReport.taskId,
);
if ("pending" in result && result.pending) {
// The cached report reference is no longer servable (e.g. the
// underlying file expired/was cleaned up). Invalidate BOTH sources
// feeding ``availableReport`` — dropping only the store entry would
// leave the server-resolved ``latestPdfReport`` prop keeping the
// button on "Download PDF" forever, telling the user to click a
// Generate button that never appears.
removeGeneration(availableReport.taskId);
setInvalidated({
signature: currentSignatureRef.current,
taskId: availableReport.taskId,
});
toast({
variant: "destructive",
title: "Report No Longer Available",
description:
"This report isn't ready to download anymore. Click Generate PDF to create a new one.",
});
return;
}
await downloadFile(
result,
"application/pdf",
"The combined compliance PDF report has been downloaded successfully.",
toast,
);
} finally {
setIsDownloading(false);
}
}, [
availableReport.taskId,
hasAvailableReport,
isDownloading,
isGenerating,
removeGeneration,
]);
const buttonClassName = cn(
"border-button-primary text-button-primary hover:bg-button-primary/10 h-8 px-2 text-xs",
className,
);
const nameDialog = (
<Dialog open={nameDialogOpen} onOpenChange={setNameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Name your report</DialogTitle>
<DialogDescription>
This becomes the downloaded PDF&apos;s filename. Leave it as is or
give it a name you&apos;ll recognize later.
</DialogDescription>
</DialogHeader>
<Input
value={reportName}
onChange={(e) => setReportName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
confirmGenerate();
}
}}
placeholder="Report name"
aria-label="Report name"
autoFocus
/>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setNameDialogOpen(false)}
>
Cancel
</Button>
<Button size="sm" onClick={confirmGenerate}>
<FileDown className="size-3" />
Generate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
let button: ReactNode;
if (isGenerating) {
button = (
<Button
variant="outline"
size="sm"
disabled
aria-label="Generating combined PDF report"
className={buttonClassName}
>
<Loader2 className="size-3 animate-spin" />
Generating...
</Button>
);
} else if (hasAvailableReport) {
const generatedAtLabel = formatGeneratedAt(availableReport.generatedAt);
const downloadButton = (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={isDownloading}
aria-label="Download combined PDF report"
className={buttonClassName}
>
{isDownloading ? (
<Loader2 className="size-3 animate-spin" />
) : (
<DownloadIcon className="size-3" />
)}
{isDownloading ? "Downloading..." : "Download PDF"}
</Button>
);
button = generatedAtLabel ? (
<Tooltip>
<TooltipTrigger asChild>{downloadButton}</TooltipTrigger>
<TooltipContent>Generated on {generatedAtLabel}</TooltipContent>
</Tooltip>
) : (
downloadButton
);
} else {
button = (
<Button
variant="outline"
size="sm"
onClick={openNameDialog}
aria-label="Generate combined PDF report"
className={buttonClassName}
>
<FileDown className="size-3" />
Generate PDF
</Button>
);
}
return (
<>
{button}
{nameDialog}
</>
);
};
@@ -1,12 +1,8 @@
"use client";
import { Check, Slash } from "lucide-react";
import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-badge-icon";
import type { ProviderCoverage } from "@/lib/compliance/cross-provider-insights";
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { getProviderLabel } from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
interface ProviderCoveragePanelProps {
@@ -14,11 +10,17 @@ interface ProviderCoveragePanelProps {
}
/**
* Right-of-score panel listing every compatible provider with its
* per-provider score and a clear "scan available" / "no scan yet"
* marker. Keeps non-contributing providers visible (dimmed) instead of
* hiding them — the user needs to know coverage gaps as much as
* coverage itself.
* Right-of-score panel listing every SCANNED provider with its
* per-provider score. In the detail view we only show providers we
* actually have scans for — framework-compatible providers with no scan
* belong on the overview (where they're listed dimmed as "no scan yet"),
* not here.
*
* Laid out as a tile grid rather than a single-column list: universal
* frameworks can declare 15+ compatible providers, and one row per
* provider made this panel several screens taller than its siblings
* (score donut, top failing domains). The account-count detail that no
* longer fits inline moves to the tile's ``title`` tooltip.
*/
export const ProviderCoveragePanel = ({
coverage,
@@ -28,70 +30,70 @@ export const ProviderCoveragePanel = ({
<h3 className="text-text-neutral-secondary text-[11px] font-semibold tracking-wider uppercase">
Provider Coverage
</h3>
<ul className="flex flex-col gap-2">
{coverage.length === 0 && (
<p className="text-text-neutral-secondary text-xs italic">
No scanned providers for this framework yet.
</p>
)}
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{coverage.map((entry) => {
const Badge = getProviderBadge(entry.key);
const label = getProviderLabel(entry.key);
const Icon = entry.contributing ? Check : Slash;
const dimmed = !entry.contributing;
const tooltip = entry.contributing
? `${label}: ${entry.scorePercent}% — ${entry.pass} pass / ${entry.fail} fail (${entry.total} total)` +
(entry.accountCount > 1
? ` · ${entry.accountCount} accounts`
: "")
: `${label}: scanned, no data yet`;
return (
<li
key={entry.key}
title={tooltip}
className={cn(
"border-border-neutral-secondary flex items-center gap-3 rounded-md border px-3 py-2",
"border-border-neutral-secondary flex flex-col gap-1.5 rounded-md border px-2.5 py-2",
dimmed && "opacity-60",
)}
>
{Badge ? <Badge size={20} /> : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-text-default flex min-w-0 items-baseline gap-1.5 truncate text-xs font-semibold">
{label}
{entry.accountCount > 1 && (
<span className="text-text-neutral-secondary text-[10px] font-medium tracking-wider uppercase">
{entry.accountCount} accounts
</span>
)}
<div className="flex items-center gap-1.5">
<ProviderBadgeIcon providerKey={entry.key} size={16} />
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-xs font-semibold">
{label}
</span>
{entry.contributing ? (
<span className="shrink-0 font-mono text-[11px] font-bold tabular-nums">
{entry.scorePercent}%
</span>
{entry.contributing ? (
<span className="font-mono text-xs font-bold tabular-nums">
{entry.scorePercent}%
</span>
) : (
<span className="text-text-neutral-secondary text-[10px] tracking-wider uppercase">
No scan
</span>
)}
</div>
<div className="bg-default-200 dark:bg-default-100/30 h-1.5 w-full overflow-hidden rounded-full">
{entry.contributing && entry.total > 0 ? (
<div
className="bg-bg-pass h-full"
style={{ width: `${entry.scorePercent}%` }}
/>
) : null}
</div>
{entry.contributing && (
<span className="text-text-neutral-secondary font-mono text-[10px]">
{entry.pass} / {entry.total} pass
{entry.fail > 0 && (
<span className="text-bg-fail ml-2">
{entry.fail} fail
</span>
)}
) : (
<span className="text-text-neutral-secondary shrink-0 text-[9px] font-medium tracking-wider uppercase">
No data
</span>
)}
</div>
<Icon
className={cn(
"size-4 shrink-0",
entry.contributing
? "text-bg-pass"
: "text-text-neutral-secondary",
<div className="bg-default-200 dark:bg-default-100/30 h-1 w-full overflow-hidden rounded-full">
{entry.contributing && entry.total > 0 ? (
<div
className="bg-bg-pass h-full"
style={{ width: `${entry.scorePercent}%` }}
/>
) : null}
</div>
{/* Always reserve this line — even for "no scan" tiles —
so every tile in the grid is the same height regardless
of which row it lands in. */}
<span className="text-text-neutral-secondary font-mono text-[9px]">
{entry.contributing ? (
<>
{entry.pass}/{entry.total} pass
{entry.fail > 0 && (
<span className="text-bg-fail ml-1.5">
{entry.fail} fail
</span>
)}
</>
) : (
<>&nbsp;</>
)}
strokeWidth={entry.contributing ? 3 : 2}
aria-hidden="true"
/>
</span>
</li>
);
})}
@@ -60,7 +60,7 @@ export const TopFailingDomainsPanel = ({
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-text-default truncate text-xs font-semibold">
<span className="text-text-neutral-primary truncate text-xs font-semibold">
{domain.name}
</span>
<span className="text-text-neutral-secondary font-mono text-[10px]">
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ProviderBadgeIcon } from "./provider-badge-icon";
describe("ProviderBadgeIcon", () => {
it("renders the registered badge component for a known provider", () => {
const { container } = render(
<ProviderBadgeIcon providerKey="aws" size={16} />,
);
// The real AWS badge is an <svg>; the initials fallback never mounts.
expect(container.querySelector("svg")).toBeInTheDocument();
expect(screen.queryByText("AW")).not.toBeInTheDocument();
});
it("falls back to an initials chip for a provider with a label but no icon", () => {
// ``linode`` is declared by the SDK's cross-provider CIS Controls
// template but has no dedicated badge component yet — the exact gap
// ``ProviderBadgeIcon`` exists to paper over.
render(<ProviderBadgeIcon providerKey="linode" size={16} />);
expect(screen.getByText("LI")).toBeInTheDocument();
});
it("hides the fallback chip from assistive tech", () => {
render(<ProviderBadgeIcon providerKey="linode" size={16} />);
expect(screen.getByText("LI")).toHaveAttribute("aria-hidden", "true");
});
it("derives fallback initials from the uppercased key when the provider is entirely unknown", () => {
// Regression guard: an unrecognized key must not throw, and should
// still render *something* identifiable rather than a blank square.
render(<ProviderBadgeIcon providerKey="zzznope" size={16} />);
expect(screen.getByText("ZZ")).toBeInTheDocument();
});
it("sizes the fallback chip from the size prop", () => {
render(<ProviderBadgeIcon providerKey="linode" size={20} />);
expect(screen.getByText("LI")).toHaveStyle({
width: "20px",
height: "20px",
});
});
});
@@ -0,0 +1,44 @@
import {
getProviderBadge,
getProviderLabel,
} from "@/lib/providers/provider-display";
import { cn } from "@/lib/utils";
interface ProviderBadgeIconProps {
providerKey: string;
size: number;
className?: string;
}
/**
* Resolves a provider key to its dedicated badge icon. Providers without
* one yet (e.g. newly onboarded backend-only providers that haven't had
* artwork added) fall back to an initials chip instead of rendering
* nothing, so new providers never show up as a blank square.
*/
export const ProviderBadgeIcon = ({
providerKey,
size,
className,
}: ProviderBadgeIconProps) => {
const Badge = getProviderBadge(providerKey);
if (Badge) {
return <Badge size={size} className={className} />;
}
const label = getProviderLabel(providerKey);
const initials = label.slice(0, 2).toUpperCase();
return (
<span
className={cn(
"bg-default-300 text-default-700 dark:bg-default-100/40 dark:text-default-500 flex shrink-0 items-center justify-center rounded-[22%] font-mono leading-none font-bold",
className,
)}
style={{ width: size, height: size, fontSize: Math.max(size * 0.4, 7) }}
aria-hidden="true"
>
{initials}
</span>
);
};
+157
View File
@@ -0,0 +1,157 @@
import { isValidElement } from "react";
import { describe, expect, it, vi } from "vitest";
// cis-controls.tsx's accordion builders transitively import the client
// accordion chain, which pulls in server-only code (next-auth) when loaded
// under vitest. Stub them the same way compliance-mapper.test.ts does —
// we're testing the mapper's data wiring, not these components' rendering.
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: () => null }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
() => ({ ComplianceAccordionRequirementTitle: () => null }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-title",
() => ({ ComplianceAccordionTitle: () => null }),
);
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import { mapComplianceData, toAccordionItems } from "./cis-controls";
import { crossProviderToMapperInput } from "./cross-provider-adapter";
const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = {
compliance_id: "cis_controls_8.1",
framework: "CIS-Controls",
name: "CIS-Controls",
version: "8.1",
description: "CIS Critical Security Controls",
compatible_providers: ["azure", "gcp"],
requested_providers: ["azure", "gcp"],
providers: ["azure", "gcp"],
scan_ids: ["scan-azure-uuid", "scan-gcp-uuid"],
scan_ids_by_provider: {
azure: ["scan-azure-uuid"],
gcp: ["scan-gcp-uuid"],
},
requirements_passed: 1,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: 1,
requirements: [
{
id: "1.1",
name: "Establish and Maintain Detailed Enterprise Asset Inventory",
description: "Establish and maintain an inventory.",
attributes: {
Section: "1. Inventory and Control of Enterprise Assets",
Function: "Identify",
AssetType: "Devices",
ImplementationGroups: ["IG1", "IG2", "IG3"],
},
status: "PASS",
providers: { azure: "PASS", gcp: "PASS" },
check_ids_by_provider: {
azure: ["azure_check_a"],
gcp: ["gcp_check_a"],
},
},
],
};
describe("cis-controls mapper — cross-provider augmentation", () => {
it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => {
// This is the exact gap that made the per-provider check grouping and
// the cross-provider findings fetch silently fall back to per-scan
// behaviour for CIS Controls 8.1: only the CSA mapper carried these
// fields until this fix.
const { attributesData, requirementsData } = crossProviderToMapperInput(
CROSS_PROVIDER_ATTRIBUTES,
);
const frameworks = mapComplianceData(attributesData, requirementsData);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
expect(requirement.providers).toEqual({ azure: "PASS", gcp: "PASS" });
expect(requirement.check_ids_by_provider).toEqual({
azure: ["azure_check_a"],
gcp: ["gcp_check_a"],
});
expect(requirement.scan_ids_by_provider).toEqual({
azure: ["scan-azure-uuid"],
gcp: ["scan-gcp-uuid"],
});
});
it("leaves the augmentation fields undefined for a plain per-scan requirement", () => {
const perScanAttributesData = {
data: [
{
type: "compliance-requirements-attributes" as const,
id: "1.1",
attributes: {
framework_description: "",
name: "Establish and Maintain Detailed Enterprise Asset Inventory",
framework: "CIS-Controls",
version: "8.1",
description: "",
attributes: {
metadata: [
{
Section: "1. Inventory and Control of Enterprise Assets",
Function: "Identify",
AssetType: "Devices",
ImplementationGroups: ["IG1"],
},
],
check_ids: ["azure_check_a"],
},
},
},
],
};
const perScanRequirementsData = {
data: [
{
type: "compliance-requirements-details" as const,
id: "1.1",
attributes: {
framework: "CIS-Controls",
version: "8.1",
description: "",
status: "PASS" as const,
},
},
],
};
const frameworks = mapComplianceData(
perScanAttributesData as any,
perScanRequirementsData as any,
);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
expect(requirement.providers).toBeUndefined();
expect(requirement.check_ids_by_provider).toBeUndefined();
expect(requirement.scan_ids_by_provider).toBeUndefined();
});
it("forwards the requirement's providers map into the accordion title", () => {
const { attributesData, requirementsData } = crossProviderToMapperInput(
CROSS_PROVIDER_ATTRIBUTES,
);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "");
const titleElement = items[0]?.items?.[0]?.title;
expect(isValidElement(titleElement)).toBe(true);
if (isValidElement(titleElement)) {
expect((titleElement.props as { providers?: unknown }).providers).toEqual(
{ azure: "PASS", gcp: "PASS" },
);
}
});
});
+14 -2
View File
@@ -6,8 +6,8 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
CISControlsAttributesMetadata,
CrossProviderRequirement,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
@@ -62,6 +62,14 @@ export const mapComplianceData = (
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
// Optional cross-provider augmentations (only present when the attribute
// item came from ``crossProviderToMapperInput``).
const providersForRequirement =
attributeItem.attributes.attributes.providers;
const checkIdsByProvider =
attributeItem.attributes.attributes.check_ids_by_provider;
const scanIdsByProvider =
attributeItem.attributes.attributes.scan_ids_by_provider;
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
@@ -69,7 +77,7 @@ export const mapComplianceData = (
const control = findOrCreateControl(category.controls, categoryName);
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
const requirement: CrossProviderRequirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
@@ -78,6 +86,9 @@ export const mapComplianceData = (
function: attrs.Function ?? undefined,
asset_type: attrs.AssetType ?? undefined,
implementation_groups: attrs.ImplementationGroups ?? undefined,
providers: providersForRequirement,
check_ids_by_provider: checkIdsByProvider,
scan_ids_by_provider: scanIdsByProvider,
};
control.requirements.push(requirement);
@@ -122,6 +133,7 @@ export const toAccordionItems = (
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
providers={requirement.providers}
/>
),
content: (
@@ -79,7 +79,7 @@ describe("computeCrossProviderInsights", () => {
expect(insights.total).toBe(5);
});
it("builds providerCoverage for every compatible provider, including non-contributing ones", () => {
it("builds providerCoverage for every scanned provider, contributing or not", () => {
const insights = computeCrossProviderInsights(buildAttributes());
const byKey = Object.fromEntries(
insights.providerCoverage.map((c) => [c.key, c]),
@@ -99,14 +99,38 @@ describe("computeCrossProviderInsights", () => {
expect(byKey.azure.total).toBe(2);
expect(byKey.azure.scorePercent).toBe(100);
// GCP is in compatible_providers but the API marks it as
// non-contributing — surfaces with zeroed counts so the panel can
// dim it instead of hiding it.
// GCP HAS a scan (it's in scan_ids_by_provider) but the API marks it
// non-contributing (no requirement row) — it still surfaces (scanned),
// with zeroed counts so the panel can dim it.
expect(byKey.gcp.contributing).toBe(false);
expect(byKey.gcp.total).toBe(0);
expect(byKey.gcp.scorePercent).toBe(0);
});
it("shows only scanned providers in the detail (hides compatible providers with no scan)", () => {
const noGcpScan: CrossProviderComplianceOverviewAttributes = {
...buildAttributes(),
// gcp stays compatible but is NOT scanned (dropped from scan_ids_by_provider).
providers: ["aws", "azure"],
scan_ids_by_provider: { aws: ["scan-aws"], azure: ["scan-azure"] },
scan_ids: ["scan-aws", "scan-azure"],
};
const insights = computeCrossProviderInsights(noGcpScan);
// Detail surfaces (coverage panel, heatmap columns) only include scanned.
expect(insights.scannedProviders).toEqual(["aws", "azure"]);
expect(insights.providerCoverage.map((c) => c.key)).toEqual([
"aws",
"azure",
]);
for (const domain of insights.domainStats) {
expect(Object.keys(domain.byProvider)).not.toContain("gcp");
}
// ...but the full compatible set is still reported (the overview uses it).
expect(insights.compatibleProviders).toContain("gcp");
});
it("aggregates per-domain stats by Section attribute, with an Other bucket fallback", () => {
const insights = computeCrossProviderInsights(buildAttributes());
const byName = Object.fromEntries(
@@ -183,7 +207,7 @@ describe("computeCrossProviderInsights", () => {
expect(insights.scorePercent).toBe(0);
expect(insights.domainStats).toEqual([]);
expect(insights.domainsByFailCount).toEqual([]);
// Compatible providers still surface (empty stats) so the coverage
// Scanned providers still surface (empty stats) so the coverage
// panel doesn't disappear.
expect(insights.providerCoverage).toHaveLength(3);
});
+20 -5
View File
@@ -62,9 +62,14 @@ export interface CrossProviderInsights {
compatibleProviders: string[];
/** Provider keys that contributed at least one row. */
contributingProviders: string[];
/** One coverage entry per compatible provider (always present, even
* for non-contributing ones — coverage UI dims them rather than
* hiding them). */
/** Providers we actually have scans for (a subset of
* ``compatibleProviders`` — the ones present in ``scan_ids_by_provider``),
* in display order. The detail view shows only these; the overview keeps
* showing every compatible provider (scanned or not). */
scannedProviders: string[];
/** One coverage entry per SCANNED provider. (The overview lists every
* compatible provider and dims the unscanned ones; the detail view
* intentionally hides providers with no scan.) */
providerCoverage: ProviderCoverage[];
/** Domain stats keyed by section name, in declared order. */
domainStats: DomainStats[];
@@ -163,7 +168,16 @@ export const computeCrossProviderInsights = (
}
const contributingSet = new Set(contributing);
const providerCoverage: ProviderCoverage[] = compatible.map((key) => {
// Providers we actually have scans for. The detail view shows only these
// (framework-compatible providers with no scan belong on the overview,
// where they're listed dimmed as "no scan yet"). Kept in ``compatible``
// display order for a stable layout; a scan can only ever exist for a
// compatible provider, so filtering ``compatible`` covers every case.
const scannedSet = new Set(Object.keys(scanIdsByProvider ?? {}));
const scannedProviders = compatible.filter((key) => scannedSet.has(key));
const providerCoverage: ProviderCoverage[] = scannedProviders.map((key) => {
const total = providerTotal.get(key) || 0;
const pass = providerPass.get(key) || 0;
const scorePct = total > 0 ? Math.floor((pass / total) * 100) : 0;
@@ -183,7 +197,7 @@ export const computeCrossProviderInsights = (
const domainStats: DomainStats[] = Array.from(domainAcc.entries()).map(
([name, acc]) => {
const byProvider: Record<string, DomainProviderStatus> = {};
for (const providerKey of compatible) {
for (const providerKey of scannedProviders) {
const statuses = acc.perProvider.get(providerKey) ?? [];
byProvider[providerKey] = aggregateDomainProviderStatuses(statuses);
}
@@ -208,6 +222,7 @@ export const computeCrossProviderInsights = (
total,
compatibleProviders: compatible,
contributingProviders: contributing,
scannedProviders,
providerCoverage,
domainStats,
domainsByFailCount,
+154
View File
@@ -0,0 +1,154 @@
import { isValidElement } from "react";
import { describe, expect, it, vi } from "vitest";
// dora.tsx's accordion builders transitively import the client accordion
// chain, which pulls in server-only code (next-auth) when loaded under
// vitest. Stub them the same way compliance-mapper.test.ts does — we're
// testing the mapper's data wiring, not these components' rendering.
vi.mock(
"@/components/compliance/compliance-accordion/client-accordion-content",
() => ({ ClientAccordionContent: () => null }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title",
() => ({ ComplianceAccordionRequirementTitle: () => null }),
);
vi.mock(
"@/components/compliance/compliance-accordion/compliance-accordion-title",
() => ({ ComplianceAccordionTitle: () => null }),
);
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
import { crossProviderToMapperInput } from "./cross-provider-adapter";
import { mapComplianceData, toAccordionItems } from "./dora";
const CROSS_PROVIDER_ATTRIBUTES: CrossProviderComplianceOverviewAttributes = {
compliance_id: "dora_2022_2554",
framework: "DORA",
name: "DORA",
version: "2022/2554",
description: "Digital Operational Resilience Act",
compatible_providers: ["aws", "azure"],
requested_providers: ["aws", "azure"],
providers: ["aws", "azure"],
scan_ids: ["scan-aws-uuid", "scan-azure-uuid"],
scan_ids_by_provider: {
aws: ["scan-aws-uuid"],
azure: ["scan-azure-uuid"],
},
requirements_passed: 1,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: 1,
requirements: [
{
id: "Art-5",
name: "ICT risk management framework",
description: "Maintain a sound ICT risk management framework.",
attributes: {
Pillar: "ICT Risk Management",
Article: "5",
ArticleTitle: "ICT risk management framework",
},
status: "PASS",
providers: { aws: "PASS", azure: "PASS" },
check_ids_by_provider: {
aws: ["aws_check_a"],
azure: ["azure_check_a"],
},
},
],
};
describe("dora mapper — cross-provider augmentation", () => {
it("propagates providers/check_ids_by_provider/scan_ids_by_provider onto the mapped requirement", () => {
// Same gap fixed for CIS Controls: only the CSA mapper carried these
// fields until this fix, silently breaking per-provider check grouping
// and the cross-provider findings fetch for DORA.
const { attributesData, requirementsData } = crossProviderToMapperInput(
CROSS_PROVIDER_ATTRIBUTES,
);
const frameworks = mapComplianceData(attributesData, requirementsData);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
expect(requirement.providers).toEqual({ aws: "PASS", azure: "PASS" });
expect(requirement.check_ids_by_provider).toEqual({
aws: ["aws_check_a"],
azure: ["azure_check_a"],
});
expect(requirement.scan_ids_by_provider).toEqual({
aws: ["scan-aws-uuid"],
azure: ["scan-azure-uuid"],
});
});
it("leaves the augmentation fields undefined for a plain per-scan requirement", () => {
const perScanAttributesData = {
data: [
{
type: "compliance-requirements-attributes" as const,
id: "Art-5",
attributes: {
framework_description: "",
name: "ICT risk management framework",
framework: "DORA",
version: "2022/2554",
description: "",
attributes: {
metadata: [
{
Pillar: "ICT Risk Management",
Article: "5",
ArticleTitle: "ICT risk management framework",
},
],
check_ids: ["aws_check_a"],
},
},
},
],
};
const perScanRequirementsData = {
data: [
{
type: "compliance-requirements-details" as const,
id: "Art-5",
attributes: {
framework: "DORA",
version: "2022/2554",
description: "",
status: "PASS" as const,
},
},
],
};
const frameworks = mapComplianceData(
perScanAttributesData as any,
perScanRequirementsData as any,
);
const requirement = frameworks[0].categories[0].controls[0].requirements[0];
expect(requirement.providers).toBeUndefined();
expect(requirement.check_ids_by_provider).toBeUndefined();
expect(requirement.scan_ids_by_provider).toBeUndefined();
});
it("forwards the requirement's providers map into the accordion title", () => {
const { attributesData, requirementsData } = crossProviderToMapperInput(
CROSS_PROVIDER_ATTRIBUTES,
);
const frameworks = mapComplianceData(attributesData, requirementsData);
const items = toAccordionItems(frameworks, "");
const titleElement = items[0]?.items?.[0]?.title;
expect(isValidElement(titleElement)).toBe(true);
if (isValidElement(titleElement)) {
expect((titleElement.props as { providers?: unknown }).providers).toEqual(
{ aws: "PASS", azure: "PASS" },
);
}
});
});
+14 -2
View File
@@ -5,9 +5,9 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
CrossProviderRequirement,
DORAAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
@@ -65,6 +65,14 @@ export const mapComplianceData = (
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
// Optional cross-provider augmentations (only present when the attribute
// item came from ``crossProviderToMapperInput``).
const providersForRequirement =
attributeItem.attributes.attributes.providers;
const checkIdsByProvider =
attributeItem.attributes.attributes.check_ids_by_provider;
const scanIdsByProvider =
attributeItem.attributes.attributes.scan_ids_by_provider;
const framework = findOrCreateFramework(frameworks, frameworkName);
const category = findOrCreateCategory(framework.categories, categoryName);
@@ -72,7 +80,7 @@ export const mapComplianceData = (
const control = findOrCreateControl(category.controls, categoryName);
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
const requirement: CrossProviderRequirement = {
name: requirementName ? `${id} - ${requirementName}` : id,
description,
status: finalStatus,
@@ -82,6 +90,9 @@ export const mapComplianceData = (
pillar: attrs.Pillar,
article: attrs.Article,
article_title: attrs.ArticleTitle,
providers: providersForRequirement,
check_ids_by_provider: checkIdsByProvider,
scan_ids_by_provider: scanIdsByProvider,
};
control.requirements.push(requirement);
@@ -135,6 +146,7 @@ export const toAccordionItems = (
name={requirement.name}
status={requirement.status as FindingStatus}
invalidConfig={requirement.invalid_config}
providers={requirement.providers}
/>
),
content: (
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { getProwlerHubComplianceUrl } from "./prowler-hub";
describe("getProwlerHubComplianceUrl", () => {
it("builds the Prowler Hub compliance page URL from the framework's compliance_id", () => {
expect(getProwlerHubComplianceUrl("cis_controls_8.1")).toBe(
"https://hub.prowler.com/compliance/cis_controls_8.1",
);
});
it("works for every universal framework in the catalogue", () => {
expect(getProwlerHubComplianceUrl("csa_ccm_4.0")).toBe(
"https://hub.prowler.com/compliance/csa_ccm_4.0",
);
expect(getProwlerHubComplianceUrl("dora_2022_2554")).toBe(
"https://hub.prowler.com/compliance/dora_2022_2554",
);
});
});
+10
View File
@@ -0,0 +1,10 @@
const PROWLER_HUB_BASE_URL = "https://hub.prowler.com/compliance";
/**
* Prowler Hub page for a universal compliance framework, keyed by its
* ``compliance_id`` (e.g. ``cis_controls_8.1``) — same id the cross-provider
* overview endpoint and ``UNIVERSAL_FRAMEWORKS`` catalogue already use, so no
* separate mapping is needed per framework.
*/
export const getProwlerHubComplianceUrl = (complianceId: string): string =>
`${PROWLER_HUB_BASE_URL}/${complianceId}`;
+8 -2
View File
@@ -188,9 +188,15 @@ export const downloadScanZip = async (
};
/**
* Generic function to download a file from base64 data
* Generic function to download a file from base64 data.
*
* Exported (not just used internally by `downloadCompliancePdf`/`downloadComplianceCsv`
* /`downloadComplianceOcsf`) so other binary-download flows built on the
* same `ScanBinaryResult`-shaped server action result — e.g. the ad-hoc
* cross-provider compliance PDF — can reuse the exact same base64→Blob→click
* download mechanics instead of re-implementing them.
*/
const downloadFile = async (
export const downloadFile = async (
result: ScanBinaryResult,
outputType: string,
successMessage: string,
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
import { getProviderBadge, getProviderLabel } from "./provider-display";
describe("getProviderBadge", () => {
it("resolves a badge for every provider added to close the CIS Controls 8.1 icon gap", () => {
// github/googleworkspace/okta/cloudflare/mongodbatlas/openstack/vercel
// were previously missing from PROVIDER_BADGE_BY_KEY, rendering blank
// squares wherever a badge was expected.
const withIcons = [
"github",
"googleworkspace",
"okta",
"cloudflare",
"mongodbatlas",
"openstack",
"vercel",
];
for (const key of withIcons) {
expect(getProviderBadge(key), key).toBeDefined();
}
});
it("returns undefined for providers that only have a fallback label", () => {
// linode/stackit/nhn/scaleway are declared by the SDK's universal
// compliance templates but have no dedicated icon component yet —
// ProviderBadgeIcon depends on this returning undefined to know when
// to render its initials fallback instead.
for (const key of ["linode", "stackit", "nhn", "scaleway"]) {
expect(getProviderBadge(key), key).toBeUndefined();
}
});
it("returns undefined for an entirely unknown provider key", () => {
expect(getProviderBadge("made-up-provider")).toBeUndefined();
});
});
describe("getProviderLabel", () => {
it("reuses the shared PROVIDER_DISPLAY_NAMES map instead of a divergent local copy", () => {
// Regression guard: an earlier version of this module hand-copied
// labels and drifted from types/providers.ts (e.g. "GCP" vs "Google
// Cloud", "Oracle Cloud" vs "Oracle Cloud Infrastructure"), producing a
// different label for the same provider on different parts of the same
// page. Every known ProviderType must match the shared map exactly.
for (const [key, label] of Object.entries(PROVIDER_DISPLAY_NAMES)) {
expect(getProviderLabel(key), key).toBe(label);
}
});
it("labels backend-only providers not yet onboarded as a ProviderType", () => {
expect(getProviderLabel("linode")).toBe("Linode");
expect(getProviderLabel("stackit")).toBe("STACKIT");
expect(getProviderLabel("nhn")).toBe("NHN Cloud");
expect(getProviderLabel("scaleway")).toBe("Scaleway");
});
it("falls back to the uppercased key for an entirely unknown provider", () => {
expect(getProviderLabel("made-up-provider")).toBe("MADE-UP-PROVIDER");
});
});
+31 -9
View File
@@ -4,12 +4,20 @@ import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import type { IconSvgProps } from "@/types/components";
import { PROVIDER_DISPLAY_NAMES, type ProviderType } from "@/types/providers";
/**
* Single source of truth for provider display metadata. Kept in
@@ -29,21 +37,35 @@ export const PROVIDER_BADGE_BY_KEY: Record<string, FC<IconSvgProps>> = {
oraclecloud: OracleCloudProviderBadge,
kubernetes: KS8ProviderBadge,
m365: M365ProviderBadge,
github: GitHubProviderBadge,
googleworkspace: GoogleWorkspaceProviderBadge,
okta: OktaProviderBadge,
cloudflare: CloudflareProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
openstack: OpenStackProviderBadge,
vercel: VercelProviderBadge,
};
export const PROVIDER_LABEL_BY_KEY: Record<string, string> = {
aws: "AWS",
azure: "Azure",
gcp: "GCP",
alibabacloud: "Alibaba Cloud",
oraclecloud: "Oracle Cloud",
kubernetes: "Kubernetes",
m365: "Microsoft 365",
/**
* Labels for providers the SDK's universal compliance templates can declare
* (e.g. cross-provider CIS Controls) that aren't onboarded as a
* ``ProviderType`` yet — no icon, account selector, or scan config exists
* for them, so they only ever reach ``getProviderLabel``'s fallback badge.
* Real providers must NOT be duplicated here: ``PROVIDER_DISPLAY_NAMES`` in
* ``@/types/providers`` is the single source of truth for those.
*/
const FALLBACK_ONLY_PROVIDER_LABELS: Record<string, string> = {
linode: "Linode",
stackit: "STACKIT",
nhn: "NHN Cloud",
scaleway: "Scaleway",
};
/** Resolve a provider label, falling back to an uppercased key. */
export const getProviderLabel = (providerKey: string): string =>
PROVIDER_LABEL_BY_KEY[providerKey] ?? providerKey.toUpperCase();
PROVIDER_DISPLAY_NAMES[providerKey as ProviderType] ??
FALLBACK_ONLY_PROVIDER_LABELS[providerKey] ??
providerKey.toUpperCase();
/** Resolve the badge component, returning ``undefined`` when no icon
* is registered for the given key (the consumer renders a fallback). */
+85
View File
@@ -0,0 +1,85 @@
import { create } from "zustand";
/** A single in-flight (or just-finished) cross-provider PDF generation. */
export interface CrossProviderPdfGeneration {
/** The generation task id returned by the server action. */
taskId: string;
/**
* The filter signature the generation was started under (framework +
* scan ids + provider filters). A ``GeneratePdfButton`` only offers a
* finished report for download when its own current signature matches
* this — a report generated under the previous filters must never be
* served under new ones.
*/
signature: string;
/**
* Absolute path + search of the page the generation was started from, so
* the "ready" toast can send the user back to exactly where they
* generated it — even if they navigated away while it was rendering.
*/
reportUrl: string;
status: "running" | "completed" | "failed";
}
interface CrossProviderPdfState {
/** Tracked generations keyed by task id. */
generations: Record<string, CrossProviderPdfGeneration>;
/** Register a newly started generation as ``running``. */
trackGeneration: (entry: Omit<CrossProviderPdfGeneration, "status">) => void;
markCompleted: (taskId: string) => void;
markFailed: (taskId: string) => void;
/** Forget a generation entirely (e.g. its file expired on download). */
removeGeneration: (taskId: string) => void;
}
const setStatus = (
generations: Record<string, CrossProviderPdfGeneration>,
taskId: string,
status: CrossProviderPdfGeneration["status"],
): Record<string, CrossProviderPdfGeneration> => {
const entry = generations[taskId];
if (!entry || entry.status === status) return generations;
return { ...generations, [taskId]: { ...entry, status } };
};
/**
* Global registry of in-flight cross-provider PDF generations.
*
* The generation job has no fixed time budget, so completion is detected by
* client-side polling. That polling lives in a single ``CrossProviderPdfWatcher``
* mounted once in the app layout (NOT in the ``GeneratePdfButton``) so the
* "ready" notification still fires after the user navigates away from the
* page they generated the report on — the button unmounts, but the watcher
* and this store persist across route changes.
*
* In-memory only (like the other UI stores): a full browser reload mid-
* generation drops tracking, which is acceptable — the reported gap is SPA
* navigation within the app, where the store stays alive.
*/
export const useCrossProviderPdfStore = create<CrossProviderPdfState>(
(set) => ({
generations: {},
trackGeneration: (entry) =>
set((state) => ({
generations: {
...state.generations,
[entry.taskId]: { ...entry, status: "running" },
},
})),
markCompleted: (taskId) =>
set((state) => ({
generations: setStatus(state.generations, taskId, "completed"),
})),
markFailed: (taskId) =>
set((state) => ({
generations: setStatus(state.generations, taskId, "failed"),
})),
removeGeneration: (taskId) =>
set((state) => {
if (!state.generations[taskId]) return state;
const next = { ...state.generations };
delete next[taskId];
return { generations: next };
}),
}),
);
+1
View File
@@ -1,3 +1,4 @@
export * from "./cross-provider-pdf/store";
export * from "./organizations/store";
export * from "./provider-wizard/store";
export * from "./scans/store";