chore(revision): add missing changes

This commit is contained in:
pedrooot committed 2026-07-07 15:55:28 +02:00
1 parent 79d592178b
commit 7c0b87c87c
16 files changed
+595 -150

No files matched your search

+6
View File
@@ -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
+37 -23
View File
@@ -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">
@@ -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");
+10 -4
View File
@@ -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 = {
+4 -8
View File
@@ -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)" },