mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-10 21:44:16 +00:00
chore(revision): add missing changes
This commit is contained in:
16 files changed
+595
-150
No files matched your search
@@ -297,6 +297,7 @@ export const generateCrossProviderCompliancePdf = async ({
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
onlyFailed,
|
||||
includeManual,
|
||||
reportName,
|
||||
@@ -306,6 +307,7 @@ export const generateCrossProviderCompliancePdf = async ({
|
||||
providerTypes?: string | string[];
|
||||
providerIds?: string | string[];
|
||||
providerGroups?: string | string[];
|
||||
regions?: string | string[];
|
||||
onlyFailed?: boolean;
|
||||
includeManual?: boolean;
|
||||
/** Optional user-chosen download filename. Sanitized server-side (a
|
||||
@@ -333,6 +335,7 @@ export const generateCrossProviderCompliancePdf = async ({
|
||||
setParam("filter[provider_type__in]", providerTypes);
|
||||
setParam("filter[provider_id__in]", providerIds);
|
||||
setParam("filter[provider_groups__in]", providerGroups);
|
||||
setParam("filter[region__in]", regions);
|
||||
setParam("report_name", reportName);
|
||||
if (onlyFailed !== undefined) {
|
||||
url.searchParams.set("only_failed", String(onlyFailed));
|
||||
@@ -465,12 +468,14 @@ export const getLatestCrossProviderCompliancePdf = async ({
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanIds?: string[];
|
||||
providerTypes?: string | string[];
|
||||
providerIds?: string | string[];
|
||||
providerGroups?: string | string[];
|
||||
regions?: string | string[];
|
||||
}): Promise<LatestCrossProviderPdfResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
@@ -493,6 +498,7 @@ export const getLatestCrossProviderCompliancePdf = async ({
|
||||
setParam("filter[provider_type__in]", providerTypes);
|
||||
setParam("filter[provider_id__in]", providerIds);
|
||||
setParam("filter[provider_groups__in]", providerGroups);
|
||||
setParam("filter[region__in]", regions);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
|
||||
@@ -94,32 +94,18 @@ export default async function ComplianceDetail({
|
||||
}
|
||||
|
||||
const crossProviderTitle = compliancetitle.split("-").join(" ");
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
const [crossProviderResponse, providersData, providerGroupsData] =
|
||||
await Promise.all([
|
||||
getCrossProviderComplianceOverview({
|
||||
complianceId,
|
||||
providerTypes: providerTypeFilter,
|
||||
providerIds: providerIdFilter,
|
||||
providerGroups: providerGroupsFilter,
|
||||
regions: regionFilter,
|
||||
}),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
|
||||
if (!crossProviderResponse || "redirectTo" in crossProviderResponse) {
|
||||
// A 402 (payment required) resolves to ``{ redirectTo: "/billing" }`` —
|
||||
@@ -181,6 +167,24 @@ export default async function ComplianceDetail({
|
||||
const compatibleProviders = (providersData?.data || []).filter((provider) =>
|
||||
compatibleProviderTypes.has(provider.attributes.provider),
|
||||
);
|
||||
|
||||
// Resolve the "latest PDF" check AFTER the overview, scoped to the EXACT
|
||||
// scans the overview resolved (``attributes.scan_ids``) rather than
|
||||
// re-deriving "latest scan per provider" from the raw filters a second
|
||||
// time. Running them independently could offer a "Download" whose scan set
|
||||
// differs from the displayed overview if a scan completed between the two
|
||||
// calls. The overview aggregation dominates the wall-clock, so serializing
|
||||
// this quick existence check behind it is a negligible cost for a
|
||||
// guaranteed-consistent download button.
|
||||
const latestPdfReport = await getLatestCrossProviderCompliancePdf({
|
||||
complianceId,
|
||||
scanIds: crossProviderData.attributes.scan_ids,
|
||||
providerTypes: providerTypeFilter,
|
||||
providerIds: providerIdFilter,
|
||||
providerGroups: providerGroupsFilter,
|
||||
regions: regionFilter,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContentLayout title={headerTitle}>
|
||||
<CrossProviderDetail
|
||||
@@ -190,6 +194,7 @@ export default async function ComplianceDetail({
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
regionFilter={regionFilter}
|
||||
latestPdfReport={
|
||||
"available" in latestPdfReport && latestPdfReport.available
|
||||
? latestPdfReport
|
||||
|
||||
@@ -53,6 +53,13 @@ export default async function Compliance({
|
||||
const activeTab = crossProviderEnabled
|
||||
? getCompliancePageTab(resolvedSearchParams.tab)
|
||||
: COMPLIANCE_PAGE_TAB.PER_SCAN;
|
||||
// The tab is server-controlled and switching tabs is a real navigation
|
||||
// (``CompliancePageTabs`` pushes ``?tab=`` and waits for the re-render), so
|
||||
// pre-building the inactive tab buys no instant-switch — it just doubles the
|
||||
// work. Build only the active tab's payload; the other is fetched on demand
|
||||
// when the user navigates to it.
|
||||
const isPerScanTab = activeTab === COMPLIANCE_PAGE_TAB.PER_SCAN;
|
||||
const isCrossProviderTab = activeTab === COMPLIANCE_PAGE_TAB.CROSS_PROVIDER;
|
||||
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
@@ -135,18 +142,22 @@ export default async function Compliance({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const metadataInfoData = selectedScanId
|
||||
? await getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
})
|
||||
: { data: { attributes: { regions: [] } } };
|
||||
// ``metadataInfo`` (region filter options) and ``threatScore`` feed only the
|
||||
// Per Scan tab UI — skip both fetches entirely when the Cross-Provider tab is
|
||||
// active so it doesn't pay for per-scan data it never renders.
|
||||
const metadataInfoData =
|
||||
isPerScanTab && selectedScanId
|
||||
? await getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
})
|
||||
: { data: { attributes: { regions: [] } } };
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
let threatScoreData = null;
|
||||
if (selectedScanId && typeof selectedScanId === "string") {
|
||||
if (isPerScanTab && selectedScanId && typeof selectedScanId === "string") {
|
||||
const threatScoreResponse = await getThreatScore({
|
||||
filters: { "filter[scan_id]": selectedScanId },
|
||||
});
|
||||
@@ -160,7 +171,7 @@ export default async function Compliance({
|
||||
}
|
||||
}
|
||||
|
||||
const perScanContent = selectedScanId ? (
|
||||
const perScanContent = !isPerScanTab ? null : selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<ComplianceFilters
|
||||
@@ -203,20 +214,23 @@ export default async function Compliance({
|
||||
<NoScansAvailable />
|
||||
);
|
||||
|
||||
// Only build (and thus fetch) the cross-provider grid in Cloud. In OSS the
|
||||
// tab is disabled, so there is no content to render and no endpoint to hit.
|
||||
const crossProviderContent = crossProviderEnabled ? (
|
||||
<Suspense
|
||||
key={`cross-provider-${searchParamsKey}`}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<SSRCrossProviderGrid searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
) : null;
|
||||
// Only build (and thus fetch) the cross-provider grid in Cloud AND only when
|
||||
// its tab is active. In OSS the tab is disabled; on the Per Scan tab it isn't
|
||||
// rendered, so there is no content to build and no aggregation endpoint to
|
||||
// hit until the user actually navigates to Cross-Provider.
|
||||
const crossProviderContent =
|
||||
crossProviderEnabled && isCrossProviderTab ? (
|
||||
<Suspense
|
||||
key={`cross-provider-${searchParamsKey}`}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<SSRCrossProviderGrid searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
|
||||
@@ -15,7 +15,10 @@ import { ProviderBadgeIcon } from "@/components/icons/providers-badge/provider-b
|
||||
import { Alert, AlertDescription, Button } from "@/components/shadcn";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { StatusFindingBadge } from "@/components/ui/table/status-finding-badge";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
@@ -37,10 +40,12 @@ interface ClientAccordionContentProps {
|
||||
disableFindings?: boolean;
|
||||
}
|
||||
|
||||
const toFindingStatus = (status: RequirementStatus) => {
|
||||
// FindingStatus shares the same wire values for PASS/FAIL/MANUAL.
|
||||
return status === "No findings" ? "MANUAL" : status;
|
||||
};
|
||||
// PASS/FAIL/MANUAL share the same wire values as FindingStatus. "No findings"
|
||||
// is NOT one of them — it means the provider's checks produced no findings at
|
||||
// all, which is neither a manual control nor a pass/fail verdict. The caller
|
||||
// renders it as a distinct neutral chip instead of coercing it into a
|
||||
// (misleading) "Manual" badge.
|
||||
const NO_FINDINGS_STATUS: RequirementStatus = "No findings";
|
||||
|
||||
export const ClientAccordionContent = ({
|
||||
requirement,
|
||||
@@ -88,6 +93,7 @@ export const ClientAccordionContent = ({
|
||||
isLoading,
|
||||
error,
|
||||
isPartial,
|
||||
crossProviderScanMeta,
|
||||
patchTriageUpdate,
|
||||
reload,
|
||||
} = useRequirementFindings({
|
||||
@@ -121,35 +127,51 @@ export const ClientAccordionContent = ({
|
||||
patchTriageUpdate(input);
|
||||
};
|
||||
|
||||
// Per-provider finding tallies for the cross-provider breakdown. Derived
|
||||
// from the merged ``findings`` (mapping each row to its provider via
|
||||
// ``scan_ids_by_provider``) so the counts always match the unified table.
|
||||
// Per-provider finding tallies for the cross-provider breakdown.
|
||||
//
|
||||
// ``count`` is authoritative: it sums each of the provider's scans' real
|
||||
// ``pagination.count`` (total findings for its checks), NOT the loaded page
|
||||
// rows — a provider with 300 findings must read 300 here even while the
|
||||
// table shows page 1.
|
||||
//
|
||||
// ``pass``/``fail`` can only be tallied from loaded rows, so they're honest
|
||||
// only when every one of the provider's scans returned all its rows on this
|
||||
// page (``pages <= 1``). When a provider is paginated, ``fullyLoaded`` is
|
||||
// false and the caller renders "—" instead of a misleading partial split.
|
||||
const providerFindingStats = (() => {
|
||||
const count: Record<string, number> = {};
|
||||
const pass: Record<string, number> = {};
|
||||
const fail: Record<string, number> = {};
|
||||
const fullyLoaded: Record<string, boolean> = {};
|
||||
if (!isCrossProvider || !scanIdsByProvider) {
|
||||
return { count, pass, fail };
|
||||
return { count, pass, fail, fullyLoaded };
|
||||
}
|
||||
const scanToProvider = new Map<string, string>();
|
||||
for (const [providerKey, scanIds] of Object.entries(scanIdsByProvider)) {
|
||||
count[providerKey] = 0;
|
||||
pass[providerKey] = 0;
|
||||
fail[providerKey] = 0;
|
||||
fullyLoaded[providerKey] = true;
|
||||
if (Array.isArray(scanIds)) {
|
||||
for (const sid of scanIds) scanToProvider.set(sid, providerKey);
|
||||
for (const sid of scanIds) {
|
||||
scanToProvider.set(sid, providerKey);
|
||||
const meta = crossProviderScanMeta[sid];
|
||||
// No meta yet (scan errored or still loading) or the scan spans
|
||||
// multiple pages → this provider's pass/fail can't be trusted.
|
||||
count[providerKey] += meta?.count ?? 0;
|
||||
if (!meta || meta.pages > 1) fullyLoaded[providerKey] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const row of findings?.data ?? []) {
|
||||
const sid = row.relationships?.scan?.data?.id;
|
||||
const providerKey = sid ? scanToProvider.get(sid) : undefined;
|
||||
if (!providerKey) continue;
|
||||
count[providerKey] += 1;
|
||||
const status = row.attributes?.status;
|
||||
if (status === "PASS") pass[providerKey] += 1;
|
||||
else if (status === "FAIL") fail[providerKey] += 1;
|
||||
}
|
||||
return { count, pass, fail };
|
||||
return { count, pass, fail, fullyLoaded };
|
||||
})();
|
||||
|
||||
const renderDetails = () => {
|
||||
@@ -200,6 +222,10 @@ export const ClientAccordionContent = ({
|
||||
const findingsCount = providerFindingStats.count[providerKey];
|
||||
const passCount = providerFindingStats.pass[providerKey] ?? 0;
|
||||
const failCount = providerFindingStats.fail[providerKey] ?? 0;
|
||||
// Pass/Fail is only honest when the provider's rows are fully
|
||||
// loaded (single page per scan); otherwise it's a page sample.
|
||||
const passFailKnown =
|
||||
providerFindingStats.fullyLoaded[providerKey] ?? false;
|
||||
return (
|
||||
<tr
|
||||
key={providerKey}
|
||||
@@ -222,15 +248,24 @@ export const ClientAccordionContent = ({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
<StatusFindingBadge
|
||||
status={toFindingStatus(providerStatus)}
|
||||
/>
|
||||
{providerStatus === NO_FINDINGS_STATUS ? (
|
||||
<span
|
||||
className="border-text-neutral-tertiary text-text-neutral-tertiary inline-flex min-w-[38px] items-center justify-center rounded border-x border-y-0 px-0 py-0.5 text-center text-xs font-bold"
|
||||
title="This provider's checks produced no findings for this requirement."
|
||||
>
|
||||
No findings
|
||||
</span>
|
||||
) : (
|
||||
<StatusFindingBadge
|
||||
status={providerStatus as FindingStatus}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-text-neutral-secondary px-3 py-2.5 text-right align-middle tabular-nums">
|
||||
{findingsLoaded ? (findingsCount ?? 0) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right align-middle">
|
||||
{findingsLoaded ? (
|
||||
{findingsLoaded && passFailKnown ? (
|
||||
<span className="font-mono tabular-nums">
|
||||
<span className="text-bg-pass">{passCount}</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
@@ -239,7 +274,16 @@ export const ClientAccordionContent = ({
|
||||
<span className="text-bg-fail">{failCount}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary">—</span>
|
||||
<span
|
||||
className="text-text-neutral-secondary"
|
||||
title={
|
||||
findingsLoaded
|
||||
? "Findings are paginated for this provider — open its scan to see the full pass/fail split."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 align-middle">
|
||||
|
||||
+7
-1
@@ -43,7 +43,13 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-neutral-primary truncate">{name}</span>
|
||||
{/* ``truncate`` keeps the row single-line so the status/chips cluster
|
||||
stays aligned, but the full name would otherwise be unreadable once
|
||||
ellipsized (most CIS/ENS/ISO names are sentence-length). ``title``
|
||||
surfaces it on hover/focus so nothing is lost. */}
|
||||
<span className="text-text-neutral-primary truncate" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
|
||||
</div>
|
||||
{/* The accordion trigger this title lives in applies ``hover:underline``
|
||||
|
||||
@@ -348,6 +348,100 @@ describe("useRequirementFindings", () => {
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(3);
|
||||
});
|
||||
|
||||
it("takes the worst-case page count across scans so pagination reaches every tail", async () => {
|
||||
findingsActionsMock.getFindings.mockReset();
|
||||
findingsActionsMock.getFindings
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "finding-a",
|
||||
attributes: { status: "FAIL", severity: "high" },
|
||||
relationships: { scan: { data: { id: "scan-a" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "scans", id: "scan-a" }],
|
||||
// AWS spans many pages…
|
||||
meta: { pagination: { count: 30, pages: 3 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "finding-b",
|
||||
attributes: { status: "PASS", severity: "low" },
|
||||
relationships: { scan: { data: { id: "scan-b" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "scans", id: "scan-b" }],
|
||||
// …Azure fits in one.
|
||||
meta: { pagination: { count: 5, pages: 1 } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(
|
||||
defaultOptions({
|
||||
isCrossProvider: true,
|
||||
scanIdsByProvider: { aws: ["scan-a"], azure: ["scan-b"] },
|
||||
checkIdsByProvider: { aws: ["check_a"], azure: ["check_b"] },
|
||||
scopeSignature: "scope-pages",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// The merged envelope must expose 3 pages (not the hardcoded 1) so the
|
||||
// table's Next button stays enabled and AWS's later pages are reachable.
|
||||
expect(result.current.findings?.meta?.pagination?.pages).toBe(3);
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(35);
|
||||
});
|
||||
|
||||
it("globally re-sorts the merged rows by the active sort (FAIL/critical first)", async () => {
|
||||
findingsActionsMock.getFindings.mockReset();
|
||||
// Scan A returns a PASS/low row; scan B a FAIL/critical row. Concatenation
|
||||
// alone would list A before B and contradict the FAIL-first sort.
|
||||
findingsActionsMock.getFindings
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "finding-pass",
|
||||
attributes: { status: "PASS", severity: "low" },
|
||||
relationships: { scan: { data: { id: "scan-a" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "scans", id: "scan-a" }],
|
||||
meta: { pagination: { count: 1, pages: 1 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "finding-fail",
|
||||
attributes: { status: "FAIL", severity: "critical" },
|
||||
relationships: { scan: { data: { id: "scan-b" } } },
|
||||
},
|
||||
],
|
||||
included: [{ type: "scans", id: "scan-b" }],
|
||||
meta: { pagination: { count: 1, pages: 1 } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(
|
||||
defaultOptions({
|
||||
// Family A default: status,severity,-inserted_at → FAIL/critical first.
|
||||
sort: "status,severity,-inserted_at",
|
||||
isCrossProvider: true,
|
||||
scanIdsByProvider: { aws: ["scan-a"], azure: ["scan-b"] },
|
||||
checkIdsByProvider: { aws: ["check_a"], azure: ["check_b"] },
|
||||
scopeSignature: "scope-sort",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(result.current.findings?.data?.map((f) => f.id)).toEqual([
|
||||
"finding-fail",
|
||||
"finding-pass",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not fetch in cross-provider mode when no scans contribute", async () => {
|
||||
// Given / When
|
||||
renderHook(() =>
|
||||
|
||||
@@ -41,6 +41,15 @@ interface UseRequirementFindingsOptions {
|
||||
scopeSignature?: string;
|
||||
}
|
||||
|
||||
// Authoritative per-scan findings metadata (the total ``count`` for that
|
||||
// scan's checks, and how many ``pages`` it spans). Sourced from each
|
||||
// per-scan response's pagination — not from the loaded rows — so the
|
||||
// per-provider breakdown can show real totals instead of a page-1 sample.
|
||||
export interface CrossProviderScanMeta {
|
||||
count: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
interface UseRequirementFindingsReturn {
|
||||
findings: FindingsResponse | null;
|
||||
expandedFindings: FindingProps[];
|
||||
@@ -51,23 +60,78 @@ interface UseRequirementFindingsReturn {
|
||||
// The caller surfaces a warning instead of presenting the partial data as
|
||||
// complete.
|
||||
isPartial: boolean;
|
||||
// Cross-provider only: authoritative per-scan totals keyed by scan id, so
|
||||
// the per-provider breakdown reports real finding counts (and can tell
|
||||
// whether a provider's rows are fully loaded) rather than a page sample.
|
||||
crossProviderScanMeta: Record<string, CrossProviderScanMeta>;
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const FINDINGS_LOAD_ERROR = "Could not load findings.";
|
||||
|
||||
// Client-side mirror of the API's plain-findings ("Family A") sort semantics
|
||||
// (see ``lib/findings-sort.ts``): ``status`` and ``severity`` are Postgres
|
||||
// ENUM columns sorted by DECLARATION order, so the bare (ascending) token
|
||||
// already puts FAIL / critical first. The merge below concatenates rows that
|
||||
// each arrived sorted per scan — without a global re-sort the blocks would
|
||||
// interleave by provider and contradict the table's sort indicator.
|
||||
const ENUM_SORT_ORDER: Record<string, Record<string, number>> = {
|
||||
severity: { critical: 0, high: 1, medium: 2, low: 3, informational: 4 },
|
||||
status: { FAIL: 0, PASS: 1, MANUAL: 2 },
|
||||
};
|
||||
|
||||
const compareBySortTokens = (
|
||||
a: FindingProps,
|
||||
b: FindingProps,
|
||||
tokens: string[],
|
||||
): number => {
|
||||
for (const token of tokens) {
|
||||
const desc = token.startsWith("-");
|
||||
const field = desc ? token.slice(1) : token;
|
||||
const rank = ENUM_SORT_ORDER[field];
|
||||
const rawA = (a.attributes as Record<string, unknown> | undefined)?.[field];
|
||||
const rawB = (b.attributes as Record<string, unknown> | undefined)?.[field];
|
||||
|
||||
let cmp = 0;
|
||||
if (rank) {
|
||||
const rankA = rank[String(rawA)] ?? Number.MAX_SAFE_INTEGER;
|
||||
const rankB = rank[String(rawB)] ?? Number.MAX_SAFE_INTEGER;
|
||||
cmp = rankA - rankB;
|
||||
} else if (typeof rawA === "number" && typeof rawB === "number") {
|
||||
cmp = rawA - rawB;
|
||||
} else {
|
||||
// ISO timestamps (``inserted_at``/``updated_at``) and plain strings
|
||||
// both compare correctly lexicographically; missing values sort last.
|
||||
const isMissing = (v: unknown) => v === null || v === undefined;
|
||||
const strA = isMissing(rawA) ? "" : String(rawA);
|
||||
const strB = isMissing(rawB) ? "" : String(rawB);
|
||||
if (strA === strB) continue;
|
||||
if (strA === "") return 1;
|
||||
if (strB === "") return -1;
|
||||
cmp = strA < strB ? -1 : 1;
|
||||
}
|
||||
if (cmp !== 0) return desc ? -cmp : cmp;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Merge the per-scan responses of a cross-provider fan-out into a single
|
||||
// JSON:API-shaped envelope: concatenate every ``data`` row, dedupe the
|
||||
// ``included`` records by ``(type, id)`` (the same provider/scan object
|
||||
// repeats across responses), and sum the per-scan counts.
|
||||
// JSON:API-shaped envelope: concatenate every ``data`` row (globally
|
||||
// re-sorted by the active sort), dedupe the ``included`` records by
|
||||
// ``(type, id)`` (the same provider/scan object repeats across responses),
|
||||
// sum the per-scan counts, and take the WORST-CASE page count so pagination
|
||||
// can reach every scan's tail (each page-N fetch fans out per scan; scans
|
||||
// past their last page simply return no rows).
|
||||
const mergeCrossProviderResponses = (
|
||||
responses: unknown[],
|
||||
page: number,
|
||||
sort: string,
|
||||
): FindingsResponseLike => {
|
||||
const allData: FindingProps[] = [];
|
||||
const allIncluded: { type: string; id: string }[] = [];
|
||||
let totalCount = 0;
|
||||
let maxPages = 1;
|
||||
|
||||
for (const r of responses) {
|
||||
if (!r || typeof r !== "object" || !("data" in r)) continue;
|
||||
@@ -75,6 +139,15 @@ const mergeCrossProviderResponses = (
|
||||
allData.push(...(typedResponse.data || []));
|
||||
allIncluded.push(...(typedResponse.included || []));
|
||||
totalCount += typedResponse?.meta?.pagination?.count || 0;
|
||||
maxPages = Math.max(maxPages, typedResponse?.meta?.pagination?.pages || 1);
|
||||
}
|
||||
|
||||
const sortTokens = sort
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (sortTokens.length > 0) {
|
||||
allData.sort((a, b) => compareBySortTokens(a, b, sortTokens));
|
||||
}
|
||||
|
||||
const dedupedIncluded: typeof allIncluded = [];
|
||||
@@ -90,7 +163,7 @@ const mergeCrossProviderResponses = (
|
||||
data: allData,
|
||||
included: dedupedIncluded,
|
||||
meta: {
|
||||
pagination: { page, pages: 1, count: totalCount },
|
||||
pagination: { page, pages: maxPages, count: totalCount },
|
||||
version: "",
|
||||
},
|
||||
};
|
||||
@@ -114,6 +187,9 @@ export function useRequirementFindings({
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPartial, setIsPartial] = useState(false);
|
||||
const [crossProviderScanMeta, setCrossProviderScanMeta] = useState<
|
||||
Record<string, CrossProviderScanMeta>
|
||||
>({});
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
// Depend on the joined value, not the array: the requirement prop gets a
|
||||
@@ -225,11 +301,26 @@ export function useRequirementFindings({
|
||||
return;
|
||||
}
|
||||
|
||||
// Authoritative per-scan totals, aligned by index with ``jobs``
|
||||
// (``responses[i]`` answers ``jobs[i]``). The breakdown reads these
|
||||
// for real per-provider counts instead of counting loaded rows.
|
||||
const scanMeta: Record<string, CrossProviderScanMeta> = {};
|
||||
responses.forEach((r, index) => {
|
||||
if (!r || typeof r !== "object" || !("data" in r)) return;
|
||||
const pagination = (r as FindingsResponseLike).meta?.pagination;
|
||||
scanMeta[jobs[index].scanIdForAccount] = {
|
||||
count: pagination?.count ?? 0,
|
||||
pages: pagination?.pages ?? 1,
|
||||
};
|
||||
});
|
||||
|
||||
const merged = mergeCrossProviderResponses(
|
||||
responses,
|
||||
parseInt(pageNumber, 10),
|
||||
encodedSort,
|
||||
);
|
||||
setFindings(merged);
|
||||
setCrossProviderScanMeta(scanMeta);
|
||||
expandFindings(merged);
|
||||
// Some — but not all — scans failed: keep the successful data but
|
||||
// flag the merge as incomplete so the caller can warn the user.
|
||||
@@ -301,6 +392,7 @@ export function useRequirementFindings({
|
||||
isLoading,
|
||||
error,
|
||||
isPartial,
|
||||
crossProviderScanMeta,
|
||||
patchTriageUpdate,
|
||||
reload,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { computeCrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
|
||||
import type { CrossProviderComplianceOverviewAttributes } from "@/types/compliance";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
@@ -17,12 +18,13 @@ interface CrossProviderDetailClientProps {
|
||||
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. */
|
||||
* ``filter[provider_groups__in]`` / ``filter[region__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;
|
||||
regionFilter?: string;
|
||||
latestPdfReport: LatestCrossProviderPdfReport | null;
|
||||
}
|
||||
|
||||
@@ -52,9 +54,16 @@ export const CrossProviderDetailClient = ({
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
regionFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderDetailClientProps) => {
|
||||
const insights = computeCrossProviderInsights(attributes);
|
||||
// Derived once per payload — ``computeCrossProviderInsights`` iterates every
|
||||
// requirement, so recomputing it on each anchor-scroll state change would be
|
||||
// wasteful.
|
||||
const insights = useMemo(
|
||||
() => computeCrossProviderInsights(attributes),
|
||||
[attributes],
|
||||
);
|
||||
|
||||
const accordionContainerRef = useRef<HTMLDivElement>(null);
|
||||
const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -63,12 +72,11 @@ export const CrossProviderDetailClient = ({
|
||||
>(null);
|
||||
|
||||
// Clear any pending flash timer on unmount so its callback never runs
|
||||
// against a detached anchor node.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
// against a detached anchor node. ``useMountEffect`` is the project-approved
|
||||
// wrapper for a mount/unmount lifecycle (never a bare ``useEffect``).
|
||||
useMountEffect(() => () => {
|
||||
if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current);
|
||||
});
|
||||
|
||||
const handleDomainSelect = (domainName: string) => {
|
||||
// Setting the forced key expands the target section. Selecting a domain
|
||||
@@ -119,6 +127,7 @@ export const CrossProviderDetailClient = ({
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
regionFilter={regionFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,12 @@ interface CrossProviderDetailProps {
|
||||
* 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. */
|
||||
* ``filter[provider_groups__in]`` / ``filter[region__in]`` values from the
|
||||
* URL — threaded down to the "Generate PDF" button. */
|
||||
providerTypeFilter?: string;
|
||||
providerIdFilter?: string;
|
||||
providerGroupsFilter?: string;
|
||||
regionFilter?: 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"
|
||||
@@ -44,6 +45,7 @@ export const CrossProviderDetail = ({
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
regionFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderDetailProps) => {
|
||||
return (
|
||||
@@ -54,6 +56,7 @@ export const CrossProviderDetail = ({
|
||||
providerTypeFilter={providerTypeFilter}
|
||||
providerIdFilter={providerIdFilter}
|
||||
providerGroupsFilter={providerGroupsFilter}
|
||||
regionFilter={regionFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
|
||||
@@ -20,7 +20,6 @@ import { FindingStatus } from "@/components/ui/table/status-finding-badge";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { crossProviderToMapperInput } from "@/lib/compliance/cross-provider-adapter";
|
||||
import type { CrossProviderInsights } from "@/lib/compliance/cross-provider-insights";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
CrossProviderComplianceOverviewAttributes,
|
||||
CrossProviderRequirement,
|
||||
@@ -39,13 +38,14 @@ interface CrossProviderExplorerCardProps {
|
||||
* 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. */
|
||||
* ``filter[provider_groups__in]`` / ``filter[region__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;
|
||||
regionFilter?: string;
|
||||
/** A previously-generated PDF matching the current filters, resolved
|
||||
* server-side — ``null`` means "Generate PDF" should show, not
|
||||
* "Download PDF". */
|
||||
@@ -85,6 +85,7 @@ export const CrossProviderExplorerCard = ({
|
||||
providerTypeFilter,
|
||||
providerIdFilter,
|
||||
providerGroupsFilter,
|
||||
regionFilter,
|
||||
latestPdfReport,
|
||||
}: CrossProviderExplorerCardProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -98,8 +99,10 @@ export const CrossProviderExplorerCard = ({
|
||||
// Filtered attributes drive both the match counter and the accordion
|
||||
// content. The unfiltered ``insights`` continue to feed the heatmap
|
||||
// matrix per section so global counts stay stable while the user
|
||||
// narrows their search.
|
||||
const filteredAttributes = (() => {
|
||||
// narrows their search. Memoised so opening/closing a section (a state
|
||||
// change unrelated to the filter inputs) doesn't re-scan every
|
||||
// requirement.
|
||||
const filteredAttributes = useMemo(() => {
|
||||
const lowerTerm = searchTerm.trim().toLowerCase();
|
||||
if (lowerTerm === "" && statusFilters.length === 0) {
|
||||
return attributes;
|
||||
@@ -113,7 +116,7 @@ export const CrossProviderExplorerCard = ({
|
||||
return haystack.includes(lowerTerm);
|
||||
});
|
||||
return { ...attributes, requirements: filteredRequirements };
|
||||
})();
|
||||
}, [attributes, searchTerm, statusFilters]);
|
||||
|
||||
const matchCount = filteredAttributes.requirements.length;
|
||||
const totalCount = attributes.requirements.length;
|
||||
@@ -122,8 +125,11 @@ export const CrossProviderExplorerCard = ({
|
||||
// Frameworks / categories / requirements derived from the mapper. We
|
||||
// run this against the *filtered* attribute set so the list shrinks
|
||||
// as the user types — sections with no surviving requirements
|
||||
// disappear entirely instead of expanding to an empty body.
|
||||
const { sections, allSectionKeys, statsByName } = (() => {
|
||||
// disappear entirely instead of expanding to an empty body. Memoised
|
||||
// because ``mapComplianceData`` re-iterates every requirement; without
|
||||
// it, every accordion open/close (a frequent interaction) would
|
||||
// re-derive the whole tree.
|
||||
const { sections, allSectionKeys, statsByName } = useMemo(() => {
|
||||
const mapper = getComplianceMapper(filteredAttributes.framework);
|
||||
const { attributesData, requirementsData } =
|
||||
crossProviderToMapperInput(filteredAttributes);
|
||||
@@ -150,7 +156,7 @@ export const CrossProviderExplorerCard = ({
|
||||
allSectionKeys: allSections.map((s) => s.key),
|
||||
statsByName: stats,
|
||||
};
|
||||
})();
|
||||
}, [filteredAttributes, insights.domainStats]);
|
||||
|
||||
const expandedKeys = (() => {
|
||||
if (allSectionsOpen) return allSectionKeys;
|
||||
@@ -224,6 +230,7 @@ export const CrossProviderExplorerCard = ({
|
||||
providerTypes={providerTypeFilter}
|
||||
providerIds={providerIdFilter}
|
||||
providerGroups={providerGroupsFilter}
|
||||
regions={regionFilter}
|
||||
latestPdfReport={latestPdfReport}
|
||||
frameworkLabel={
|
||||
attributes.name ||
|
||||
@@ -292,13 +299,7 @@ export const CrossProviderExplorerCard = ({
|
||||
value={section.key}
|
||||
className="px-3"
|
||||
>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"py-3",
|
||||
forcedExpandedSectionKey === section.key &&
|
||||
"data-[flash=1]:bg-bg-fail/10",
|
||||
)}
|
||||
>
|
||||
<AccordionTrigger className="py-3">
|
||||
{stats ? (
|
||||
<CrossProviderDomainTitle
|
||||
name={section.categoryName}
|
||||
@@ -344,9 +345,14 @@ const SectionRequirements = ({
|
||||
if (requirements.length === 0) return null;
|
||||
return (
|
||||
<Accordion type="multiple" className="flex flex-col">
|
||||
{requirements.map((requirement, idx) => {
|
||||
{requirements.map((requirement) => {
|
||||
const xprov = requirement as CrossProviderRequirement;
|
||||
const itemKey = `${sectionKey}-req-${idx}`;
|
||||
// Key off the requirement's stable name (it embeds the unique
|
||||
// framework requirement id, e.g. "AAC-01 - …") rather than the array
|
||||
// index: with an index key, filtering the list would hand an
|
||||
// already-open row's expansion state to whatever requirement slid into
|
||||
// that position.
|
||||
const itemKey = `${sectionKey}-req-${requirement.name}`;
|
||||
return (
|
||||
<AccordionItem
|
||||
key={itemKey}
|
||||
|
||||
@@ -131,6 +131,33 @@ describe("CrossProviderPdfWatcher", () => {
|
||||
).toBe("failed");
|
||||
});
|
||||
|
||||
it("gives up on a generation wedged in a running state past the ceiling", async () => {
|
||||
// The task never leaves ``executing`` — the watcher must eventually stop
|
||||
// polling it instead of ticking forever.
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: { attributes: { state: "executing" } },
|
||||
});
|
||||
render(<CrossProviderPdfWatcher />);
|
||||
|
||||
useCrossProviderPdfStore.getState().trackGeneration({
|
||||
taskId: "task-wedged",
|
||||
signature: "sig",
|
||||
reportUrl: "/x",
|
||||
});
|
||||
|
||||
// 200 ticks is the ceiling; advance just past it.
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 201);
|
||||
|
||||
expect(
|
||||
useCrossProviderPdfStore.getState().generations["task-wedged"].status,
|
||||
).toBe("failed");
|
||||
const timeoutToast = toastMock.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((payload) => payload.title === "PDF generation timed out");
|
||||
expect(timeoutToast).toBeTruthy();
|
||||
expect(timeoutToast.variant).toBe("destructive");
|
||||
});
|
||||
|
||||
it("surfaces a terminal failure state with the task's error message", async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@@ -28,19 +28,18 @@ const RUNNING_STATES = new Set<TaskState>([
|
||||
// consecutive failures.
|
||||
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
||||
|
||||
// Hard ceiling on how long a single generation is polled. A task wedged in
|
||||
// ``executing`` server-side would otherwise be polled forever; after this many
|
||||
// ticks (~10 min at a 3s interval) we stop tracking it and tell the user.
|
||||
const MAX_POLL_TICKS = 200;
|
||||
|
||||
/**
|
||||
* 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").
|
||||
* Inner poller. Mounted by ``CrossProviderPdfWatcher`` ONLY while at least one
|
||||
* generation is running, so the app-wide 3s tick exists exclusively during
|
||||
* active generations — when everything has settled this component unmounts and
|
||||
* the interval is torn down (rather than ticking forever over an empty set).
|
||||
*/
|
||||
export const CrossProviderPdfWatcher = () => {
|
||||
const PdfGenerationPoller = () => {
|
||||
const generations = useCrossProviderPdfStore((state) => state.generations);
|
||||
const markCompleted = useCrossProviderPdfStore(
|
||||
(state) => state.markCompleted,
|
||||
@@ -52,11 +51,12 @@ export const CrossProviderPdfWatcher = () => {
|
||||
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(),
|
||||
);
|
||||
// Per-task bookkeeping (in-flight tick + consecutive error count + elapsed
|
||||
// ticks) that must NOT trigger React re-renders — a plain ref keyed by task
|
||||
// id.
|
||||
const pollStateRef = useRef<
|
||||
Map<string, { busy: boolean; errors: number; ticks: 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
|
||||
@@ -80,9 +80,25 @@ export const CrossProviderPdfWatcher = () => {
|
||||
for (const generation of running) {
|
||||
let pollState = pollStateRef.current.get(generation.taskId);
|
||||
if (!pollState) {
|
||||
pollState = { busy: false, errors: 0 };
|
||||
pollState = { busy: false, errors: 0, ticks: 0 };
|
||||
pollStateRef.current.set(generation.taskId, pollState);
|
||||
}
|
||||
|
||||
// Count elapsed ticks even while a poll is in flight, then give up on a
|
||||
// generation that has run past the ceiling — a wedged task must not be
|
||||
// polled indefinitely.
|
||||
pollState.ticks += 1;
|
||||
if (pollState.ticks > MAX_POLL_TICKS) {
|
||||
markFailed(generation.taskId);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "PDF generation timed out",
|
||||
description:
|
||||
"The report is taking longer than expected. Please try generating it again.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -153,3 +169,31 @@ export const CrossProviderPdfWatcher = () => {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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").
|
||||
*
|
||||
* The actual interval lives in ``PdfGenerationPoller``, mounted here only while
|
||||
* a generation is running: instead of a bare ``useEffect`` guard inside the
|
||||
* poller, conditional mounting means the app carries no ticking timer at all
|
||||
* once every generation has settled.
|
||||
*/
|
||||
export const CrossProviderPdfWatcher = () => {
|
||||
const hasRunningGeneration = useCrossProviderPdfStore((state) =>
|
||||
Object.values(state.generations).some(
|
||||
(generation) => generation.status === "running",
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasRunningGeneration) return null;
|
||||
return <PdfGenerationPoller />;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { DownloadIcon, FileDown, Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LatestCrossProviderPdfReport } from "@/actions/compliances";
|
||||
import {
|
||||
@@ -40,16 +40,20 @@ interface GeneratePdfButtonProps {
|
||||
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.
|
||||
* ``filter[provider_groups__in]`` / ``filter[region__in]`` values currently
|
||||
* applied to the on-screen view (raw comma-separated strings straight from
|
||||
* the URL search params). ``scanIds`` is already narrowed by the provider
|
||||
* filters — 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. ``regions`` is NOT reflected in ``scanIds`` (a scan spans
|
||||
* every region), so forwarding it is what actually scopes the PDF to the
|
||||
* region-narrowed view the user sees.
|
||||
*/
|
||||
providerTypes?: string;
|
||||
providerIds?: string;
|
||||
providerGroups?: string;
|
||||
regions?: string;
|
||||
/**
|
||||
* A report already generated for these exact filters, resolved
|
||||
* server-side alongside the page's data fetch — ``null`` means none
|
||||
@@ -119,6 +123,7 @@ export const GeneratePdfButton = ({
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
latestPdfReport,
|
||||
frameworkLabel,
|
||||
className,
|
||||
@@ -161,6 +166,7 @@ export const GeneratePdfButton = ({
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
].join("|");
|
||||
|
||||
// The invalidation only applies to the signature it happened under; once
|
||||
@@ -201,17 +207,14 @@ export const GeneratePdfButton = ({
|
||||
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 = async (chosenName: string) => {
|
||||
if (isGenerating) return;
|
||||
setIsStarting(true);
|
||||
const signature = currentSignatureRef.current;
|
||||
// ``handleGenerate`` is redefined every render, so it already closes over
|
||||
// this render's ``filterSignature`` — capture it up front (before any
|
||||
// await) so the store entry is tagged with the filters in effect at click
|
||||
// time. No ref needed.
|
||||
const signature = filterSignature;
|
||||
// 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.
|
||||
@@ -229,6 +232,7 @@ export const GeneratePdfButton = ({
|
||||
providerTypes,
|
||||
providerIds,
|
||||
providerGroups,
|
||||
regions,
|
||||
// Empty → server falls back to a unique timestamped default.
|
||||
reportName: trimmedName.length > 0 ? trimmedName : undefined,
|
||||
});
|
||||
@@ -280,7 +284,7 @@ export const GeneratePdfButton = ({
|
||||
// Generate button that never appears.
|
||||
removeGeneration(availableReport.taskId);
|
||||
setInvalidated({
|
||||
signature: currentSignatureRef.current,
|
||||
signature: filterSignature,
|
||||
taskId: availableReport.taskId,
|
||||
});
|
||||
toast({
|
||||
|
||||
@@ -146,6 +146,95 @@ describe("computeCrossProviderInsights", () => {
|
||||
expect(byName.Other.total).toBe(1);
|
||||
});
|
||||
|
||||
it("groups DORA-shaped requirements (Pillar, no Section) by Pillar — not Other", () => {
|
||||
// DORA's universal JSON carries Pillar/Article/ArticleTitle only. The
|
||||
// domain key must match the DORA mapper's ``categoryName`` (the Pillar)
|
||||
// or the accordion's per-section stats lookup never hits.
|
||||
const doraAttributes: CrossProviderComplianceOverviewAttributes = {
|
||||
...buildAttributes(),
|
||||
compliance_id: "dora_2022_2554",
|
||||
framework: "DORA",
|
||||
name: "DORA",
|
||||
requirements: [
|
||||
{
|
||||
id: "art5",
|
||||
name: "Governance",
|
||||
description: "",
|
||||
attributes: {
|
||||
Pillar: "ICT Risk Management",
|
||||
Article: "Article 5",
|
||||
ArticleTitle: "Governance and organisation",
|
||||
},
|
||||
status: "FAIL",
|
||||
providers: { aws: "FAIL" },
|
||||
},
|
||||
{
|
||||
id: "art9",
|
||||
name: "Protection",
|
||||
description: "",
|
||||
attributes: {
|
||||
Pillar: "ICT Risk Management",
|
||||
Article: "Article 9",
|
||||
ArticleTitle: "Protection and prevention",
|
||||
},
|
||||
status: "PASS",
|
||||
providers: { aws: "PASS" },
|
||||
},
|
||||
{
|
||||
id: "art17",
|
||||
name: "Incidents",
|
||||
description: "",
|
||||
attributes: {
|
||||
Pillar: "ICT Incident Management",
|
||||
Article: "Article 17",
|
||||
ArticleTitle: "ICT-related incident management process",
|
||||
},
|
||||
status: "MANUAL",
|
||||
providers: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const insights = computeCrossProviderInsights(doraAttributes);
|
||||
const names = insights.domainStats.map((d) => d.name).sort();
|
||||
expect(names).toEqual(["ICT Incident Management", "ICT Risk Management"]);
|
||||
expect(names).not.toContain("Other");
|
||||
|
||||
const riskMgmt = insights.domainStats.find(
|
||||
(d) => d.name === "ICT Risk Management",
|
||||
);
|
||||
expect(riskMgmt?.total).toBe(2);
|
||||
expect(riskMgmt?.fail).toBe(1);
|
||||
expect(riskMgmt?.pass).toBe(1);
|
||||
expect(riskMgmt?.byProvider.aws).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("falls back to Other for non-string or empty Section/Pillar values", () => {
|
||||
const weird: CrossProviderComplianceOverviewAttributes = {
|
||||
...buildAttributes(),
|
||||
requirements: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "numeric section",
|
||||
description: "",
|
||||
attributes: { Section: 3 },
|
||||
status: "PASS",
|
||||
providers: { aws: "PASS" },
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "empty pillar",
|
||||
description: "",
|
||||
attributes: { Pillar: "" },
|
||||
status: "FAIL",
|
||||
providers: { aws: "FAIL" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const insights = computeCrossProviderInsights(weird);
|
||||
expect(insights.domainStats.map((d) => d.name)).toEqual(["Other"]);
|
||||
expect(insights.domainStats[0].total).toBe(2);
|
||||
});
|
||||
|
||||
it("rolls each domain's per-provider status with FAIL > PASS > MANUAL > NO_ROW", () => {
|
||||
const insights = computeCrossProviderInsights(buildAttributes());
|
||||
const audit = insights.domainStats.find((d) => d.name === "Audit");
|
||||
|
||||
@@ -120,9 +120,11 @@ export const computeCrossProviderInsights = (
|
||||
const providerFail = new Map<string, number>();
|
||||
const providerTotal = new Map<string, number>();
|
||||
|
||||
// Domain accumulators are keyed by section name. We only know the
|
||||
// section once we read ``req.attributes.Section`` — fall back to a
|
||||
// generic bucket so requirements without the field still surface
|
||||
// Domain accumulators are keyed by domain name. The key must match the
|
||||
// ``categoryName`` each framework's mapper derives (the accordion looks up
|
||||
// these stats by that name): CSA-CCM and CIS-Controls group by
|
||||
// ``attributes.Section``, DORA by ``attributes.Pillar``. Fall back to a
|
||||
// generic bucket so requirements without either field still surface
|
||||
// somewhere instead of silently dropping out.
|
||||
const domainAcc = new Map<
|
||||
string,
|
||||
@@ -136,8 +138,12 @@ export const computeCrossProviderInsights = (
|
||||
>();
|
||||
|
||||
for (const req of requirements) {
|
||||
const attrs = req.attributes as
|
||||
| { Section?: unknown; Pillar?: unknown }
|
||||
| undefined;
|
||||
const rawDomain = attrs?.Section ?? attrs?.Pillar;
|
||||
const section =
|
||||
(req.attributes as { Section?: string } | undefined)?.Section || "Other";
|
||||
typeof rawDomain === "string" && rawDomain !== "" ? rawDomain : "Other";
|
||||
let domain = domainAcc.get(section);
|
||||
if (!domain) {
|
||||
domain = {
|
||||
|
||||
@@ -98,14 +98,10 @@ module.exports = {
|
||||
collapse: "collapse 400ms linear 0s 1 normal forwards running",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
// ``accordion-down`` / ``accordion-up`` are defined once, in
|
||||
// ``styles/globals.css`` (alongside the Tailwind 4 ``--animate-*``
|
||||
// tokens that reference them). They used to be duplicated here but
|
||||
// nothing referenced this copy, so it lived as a silent second source.
|
||||
"collapsible-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-collapsible-content-height)" },
|
||||
|
||||
Reference in new issue
Block a user