mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(ui): correct IaC findings counters (#10736)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🐞 Fixed
|
||||
|
||||
- Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734)
|
||||
- Findings grouped view now handles zero-resource IaC counters, refines drawer loading states, and adds provider indicators to finding groups [(#10736)](https://github.com/prowler-cloud/prowler/pull/10736)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -115,4 +115,35 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
expect(result[0].id).toBe("finding-1");
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
});
|
||||
|
||||
it("should normalize a single finding response into a one-item drawer array", () => {
|
||||
// Given — getFindingById returns a single JSON:API resource object
|
||||
const input = {
|
||||
data: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: {
|
||||
checktitle: "S3 Check",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: null },
|
||||
},
|
||||
},
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("finding-1");
|
||||
expect(result[0].checkTitle).toBe("S3 Check");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,16 +165,18 @@ type IncludedDict = Record<string, IncludedItem>;
|
||||
* then resolves each finding's resource and provider relationships.
|
||||
*/
|
||||
interface JsonApiResponse {
|
||||
data: FindingApiItem[];
|
||||
data: FindingApiItem | FindingApiItem[];
|
||||
included?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function isJsonApiResponse(value: unknown): value is JsonApiResponse {
|
||||
const data = (value as { data?: unknown })?.data;
|
||||
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"data" in value &&
|
||||
Array.isArray((value as { data: unknown }).data)
|
||||
(Array.isArray(data) || (data !== null && typeof data === "object"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,8 +190,11 @@ export function adaptFindingsByResourceResponse(
|
||||
const resourcesDict = createDict("resources", apiResponse) as IncludedDict;
|
||||
const scansDict = createDict("scans", apiResponse) as IncludedDict;
|
||||
const providersDict = createDict("providers", apiResponse) as IncludedDict;
|
||||
const findings = Array.isArray(apiResponse.data)
|
||||
? apiResponse.data
|
||||
: [apiResponse.data];
|
||||
|
||||
return apiResponse.data.map((item) => {
|
||||
return findings.map((item) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = (attrs.check_metadata || {}) as Record<string, unknown>;
|
||||
const remediationRaw = meta.remediation as
|
||||
|
||||
@@ -141,7 +141,15 @@ export const getLatestMetadataInfo = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const getFindingById = async (findingId: string, include = "") => {
|
||||
interface GetFindingByIdOptions {
|
||||
source?: "resource-detail-drawer";
|
||||
}
|
||||
|
||||
export const getFindingById = async (
|
||||
findingId: string,
|
||||
include = "",
|
||||
_options?: GetFindingByIdOptions,
|
||||
) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
|
||||
|
||||
@@ -78,6 +78,18 @@ vi.mock("./notification-indicator", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("./provider-icon-cell", () => ({
|
||||
ProviderIconCell: ({ provider }: { provider: string }) => (
|
||||
<span data-testid={`provider-icon-${provider}`}>{provider}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,6 +160,26 @@ function renderFindingCell(
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
function renderFindingGroupTitleCell(overrides?: Partial<FindingGroupRow>) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
});
|
||||
|
||||
const findingColumn = columns.find(
|
||||
(col) => (col as { accessorKey?: string }).accessorKey === "finding",
|
||||
);
|
||||
if (!findingColumn?.cell) throw new Error("finding column not found");
|
||||
|
||||
const group = makeGroup(overrides);
|
||||
const CellComponent = findingColumn.cell as (props: {
|
||||
row: { original: FindingGroupRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
function renderImpactedResourcesCell(overrides?: Partial<FindingGroupRow>) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
@@ -171,11 +203,13 @@ function renderImpactedResourcesCell(overrides?: Partial<FindingGroupRow>) {
|
||||
}
|
||||
|
||||
function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const toggleSelected = vi.fn();
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown: vi.fn(),
|
||||
onDrillDown,
|
||||
});
|
||||
|
||||
const selectColumn = columns.find(
|
||||
@@ -206,7 +240,7 @@ function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
</div>,
|
||||
);
|
||||
|
||||
return { toggleSelected };
|
||||
return { onDrillDown, toggleSelected };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -231,6 +265,15 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(impactedProvidersColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should render the first provider icon with its provider name", () => {
|
||||
// Given
|
||||
renderFindingGroupTitleCell({ providers: ["iac"] });
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("provider-icon-iac")).toBeInTheDocument();
|
||||
expect(screen.getByText("Infrastructure as Code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the check title as a button element (not a <p>)", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
@@ -332,6 +375,47 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep zero-resource fallback groups non-clickable even when fallback counts are present", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
renderFindingCell("Fallback IaC Check", onDrillDown, {
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 2,
|
||||
manualCount: 1,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Fallback IaC Check" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should keep fallback groups non-clickable when the displayed total is zero", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("No failing findings", onDrillDown, {
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "No failing findings" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No failing findings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — impacted resources count", () => {
|
||||
@@ -345,6 +429,36 @@ describe("column-finding-groups — impacted resources count", () => {
|
||||
// Then
|
||||
expect(screen.getByText("3/5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to finding counts when resources total is zero", () => {
|
||||
// Given/When
|
||||
renderImpactedResourcesCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 3,
|
||||
passCount: 2,
|
||||
muted: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("3/5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should include muted findings in the denominator when the row is muted", () => {
|
||||
// Given/When
|
||||
renderImpactedResourcesCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 3,
|
||||
passCount: 2,
|
||||
failMutedCount: 4,
|
||||
passMutedCount: 1,
|
||||
muted: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("3/10")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — group selection", () => {
|
||||
@@ -357,6 +471,42 @@ describe("column-finding-groups — group selection", () => {
|
||||
|
||||
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should hide the chevron for zero-resource fallback groups even when fallback counts are present", () => {
|
||||
// Given
|
||||
const { onDrillDown } = renderSelectCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 2,
|
||||
manualCount: 1,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Expand S3 Bucket Public Access",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hide the chevron for zero-resource groups when the displayed total is zero", () => {
|
||||
// Given/When
|
||||
renderSelectCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Expand S3 Bucket Public Access",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — indicators", () => {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
DataTableColumnHeader,
|
||||
SeverityBadge,
|
||||
@@ -11,15 +16,19 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib";
|
||||
import {
|
||||
canDrillDownFindingGroup,
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupImpactedCounts,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import { NotificationIndicator } from "./notification-indicator";
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
interface GetColumnFindingGroupsOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
@@ -83,14 +92,7 @@ export function getColumnFindingGroups({
|
||||
const allMuted = isFindingGroupMuted(group);
|
||||
const isExpanded = expandedCheckId === group.checkId;
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
: deltaKey === "changed"
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const canExpand = group.resourcesTotal > 0;
|
||||
const canExpand = canDrillDownFindingGroup(group);
|
||||
const canSelect = canMuteFindingGroup({
|
||||
resourcesFail: group.resourcesFail,
|
||||
resourcesTotal: group.resourcesTotal,
|
||||
@@ -101,7 +103,7 @@ export function getColumnFindingGroups({
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
delta={deltaKey}
|
||||
isMuted={allMuted}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
@@ -175,23 +177,43 @@ export function getColumnFindingGroups({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
const canExpand = group.resourcesTotal > 0;
|
||||
const canExpand = canDrillDownFindingGroup(group);
|
||||
const provider = group.providers[0];
|
||||
const providerName = provider
|
||||
? getProviderDisplayName(provider)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-neutral-primary hover:text-button-tertiary w-full cursor-pointer border-none bg-transparent p-0 text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-neutral-primary w-full text-left text-sm break-words whitespace-normal">
|
||||
{group.checkTitle}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{provider && providerName ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="shrink-0">
|
||||
<ProviderIconCell
|
||||
provider={provider}
|
||||
size={20}
|
||||
className="size-5 rounded-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{providerName}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div>
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-neutral-primary hover:text-button-tertiary w-full cursor-pointer border-none bg-transparent p-0 text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-neutral-primary w-full text-left text-sm break-words whitespace-normal">
|
||||
{group.checkTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -216,10 +238,11 @@ export function getColumnFindingGroups({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
const counts = getFindingGroupImpactedCounts(group);
|
||||
return (
|
||||
<ImpactedResourcesCell
|
||||
impacted={group.resourcesFail}
|
||||
total={group.resourcesTotal}
|
||||
impacted={counts.impacted}
|
||||
total={counts.total}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -30,7 +30,6 @@ export function FindingDetailDrawer({
|
||||
}: FindingDetailDrawerProps) {
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources: [findingToFindingResourceRow(finding)],
|
||||
checkId: finding.attributes.check_id,
|
||||
totalResourceCount: 1,
|
||||
initialIndex: defaultOpen || inline ? 0 : null,
|
||||
});
|
||||
@@ -63,6 +62,7 @@ export function FindingDetailDrawer({
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
@@ -87,6 +87,7 @@ export function FindingDetailDrawer({
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource
|
||||
import { cn, hasHistoricalFindingFilter } from "@/lib";
|
||||
import {
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupImpactedCounts,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
@@ -30,7 +31,8 @@ import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import { getFindingGroupEmptyStateMessage } from "./inline-resource-container.utils";
|
||||
import { NotificationIndicator } from "./notification-indicator";
|
||||
import { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
|
||||
interface FindingsGroupDrillDownProps {
|
||||
@@ -96,14 +98,8 @@ export function FindingsGroupDrillDown({
|
||||
|
||||
// Delta for the sticky header
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
: deltaKey === "changed"
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const allMuted = isFindingGroupMuted(group);
|
||||
const impactedCounts = getFindingGroupImpactedCounts(group);
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
@@ -139,7 +135,7 @@ export function FindingsGroupDrillDown({
|
||||
|
||||
{/* Notification indicator */}
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
delta={deltaKey}
|
||||
isMuted={allMuted}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
@@ -159,8 +155,8 @@ export function FindingsGroupDrillDown({
|
||||
|
||||
{/* Impacted resources count */}
|
||||
<ImpactedResourcesCell
|
||||
impacted={group.resourcesFail}
|
||||
total={group.resourcesTotal}
|
||||
impacted={impactedCounts.impacted}
|
||||
total={impactedCounts.total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,9 +205,7 @@ export function FindingsGroupDrillDown({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{Object.keys(filters).length > 0
|
||||
? "No resources found for the selected filters."
|
||||
: "No resources found."}
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
@@ -248,8 +242,10 @@ export function FindingsGroupDrillDown({
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
showSyntheticResourceHint={group.resourcesTotal === 0}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
@@ -140,7 +141,7 @@ export function FindingsGroupTable({
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
// No resources in the group → nothing to show, skip drill-down
|
||||
if (group.resourcesTotal === 0) return;
|
||||
if (!canDrillDownFindingGroup(group)) return;
|
||||
|
||||
// Toggle: same group = collapse, different = switch
|
||||
if (expandedCheckId === checkId) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
getFilteredFindingGroupResourceCount,
|
||||
getFindingGroupEmptyStateMessage,
|
||||
getFindingGroupSkeletonCount,
|
||||
} from "./inline-resource-container.utils";
|
||||
import { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
@@ -278,9 +279,7 @@ export function InlineResourceContainer({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{Object.keys(filters).length > 0
|
||||
? "No resources found for the selected filters."
|
||||
: "No resources found."}
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -334,8 +333,10 @@ export function InlineResourceContainer({
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentResource={drawer.currentResource}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
showSyntheticResourceHint={group.resourcesTotal === 0}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import {
|
||||
getFilteredFindingGroupResourceCount,
|
||||
getFindingGroupEmptyStateMessage,
|
||||
getFindingGroupSkeletonCount,
|
||||
isFailOnlyStatusFilter,
|
||||
} from "./inline-resource-container.utils";
|
||||
@@ -99,3 +100,47 @@ describe("getFindingGroupSkeletonCount", () => {
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFindingGroupEmptyStateMessage", () => {
|
||||
it("returns the muted hint when muted findings are excluded and no visible resources remain", () => {
|
||||
expect(
|
||||
getFindingGroupEmptyStateMessage(
|
||||
makeGroup({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
mutedCount: 1,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
}),
|
||||
{
|
||||
"filter[status]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
"No resources match the current filters. Try enabling Include muted to view muted findings.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the generic filtered empty state when muted findings are already included", () => {
|
||||
expect(
|
||||
getFindingGroupEmptyStateMessage(
|
||||
makeGroup({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
mutedCount: 1,
|
||||
}),
|
||||
{
|
||||
"filter[status]": "FAIL",
|
||||
"filter[muted]": "include",
|
||||
},
|
||||
),
|
||||
).toBe("No resources found for the selected filters.");
|
||||
});
|
||||
|
||||
it("keeps the generic empty state when no filters are active", () => {
|
||||
expect(getFindingGroupEmptyStateMessage(makeGroup(), {})).toBe(
|
||||
"No resources found.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,18 @@ export function isFailOnlyStatusFilter(
|
||||
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
|
||||
}
|
||||
|
||||
function includesMutedFindings(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const mutedFilter = filters["filter[muted]"];
|
||||
|
||||
if (Array.isArray(mutedFilter)) {
|
||||
return mutedFilter.includes("include");
|
||||
}
|
||||
|
||||
return mutedFilter === "include";
|
||||
}
|
||||
|
||||
export function getFilteredFindingGroupResourceCount(
|
||||
group: FindingGroupRow,
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
@@ -53,3 +65,24 @@ export function getFindingGroupSkeletonCount(
|
||||
// empty state ("No resources found") replaces the skeleton.
|
||||
return Math.max(1, Math.min(filteredTotal, maxSkeletonRows));
|
||||
}
|
||||
|
||||
export function getFindingGroupEmptyStateMessage(
|
||||
group: FindingGroupRow,
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const hasFilters = Object.keys(filters).length > 0;
|
||||
|
||||
if (!hasFilters) {
|
||||
return "No resources found.";
|
||||
}
|
||||
|
||||
const mutedExcluded = !includesMutedFindings(filters);
|
||||
const hasMutedFindings = (group.mutedCount ?? 0) > 0;
|
||||
const visibleCount = getFilteredFindingGroupResourceCount(group, filters);
|
||||
|
||||
if (mutedExcluded && hasMutedFindings && visibleCount === 0) {
|
||||
return "No resources match the current filters. Try enabling Include muted to view muted findings.";
|
||||
}
|
||||
|
||||
return "No resources found for the selected filters.";
|
||||
}
|
||||
|
||||
@@ -17,14 +17,11 @@ import {
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FINDING_DELTA, type FindingDelta } from "@/types";
|
||||
|
||||
export const DeltaValues = {
|
||||
NEW: "new",
|
||||
CHANGED: "changed",
|
||||
NONE: "none",
|
||||
} as const;
|
||||
export const DeltaValues = FINDING_DELTA;
|
||||
|
||||
export type DeltaType = (typeof DeltaValues)[keyof typeof DeltaValues];
|
||||
export type DeltaType = Exclude<FindingDelta, null>;
|
||||
|
||||
interface NotificationIndicatorProps {
|
||||
delta?: DeltaType;
|
||||
@@ -124,12 +121,12 @@ function MutedIndicator({ mutedReason }: { mutedReason?: string }) {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-4 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
|
||||
className="flex w-5 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<MutedIcon className="text-bg-data-muted size-2" />
|
||||
<MutedIcon className="text-bg-data-muted size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
+412
-1
@@ -134,7 +134,12 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: () => <div />,
|
||||
Skeleton: ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { className?: string }) => (
|
||||
<div data-testid="inline-skeleton" className={className} {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
@@ -309,6 +314,7 @@ vi.mock("../../muted", () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -374,6 +380,29 @@ const mockFinding: ResourceDrawerFinding = {
|
||||
scan: null,
|
||||
};
|
||||
|
||||
const mockResourceRow: FindingResourceRow = {
|
||||
id: "row-1",
|
||||
rowType: "resource",
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
resourceName: "my-bucket",
|
||||
resourceType: "Bucket",
|
||||
resourceGroup: "default",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
delta: null,
|
||||
isMuted: false,
|
||||
mutedReason: undefined,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: Lighthouse AI button text change
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -937,3 +966,385 @@ describe("ResourceDetailDrawerContent — other findings mute refresh", () => {
|
||||
expect(onMuteComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — synthetic resource empty state", () => {
|
||||
it("should explain that simulated IaC resources never have other findings", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
showSyntheticResourceHint
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText(
|
||||
"No other findings are available for this IaC resource.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — current resource row display", () => {
|
||||
it("should render resource card fields from the current resource row instead of the fetched finding", () => {
|
||||
// Given
|
||||
const currentResource: FindingResourceRow = {
|
||||
...mockResourceRow,
|
||||
providerAlias: "row-account",
|
||||
providerUid: "row-provider-uid",
|
||||
resourceName: "row-resource-name",
|
||||
resourceUid: "row-resource-uid",
|
||||
service: "row-service",
|
||||
region: "eu-west-1",
|
||||
resourceType: "row-type",
|
||||
resourceGroup: "row-group",
|
||||
severity: "low",
|
||||
status: "PASS",
|
||||
};
|
||||
const fetchedFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
providerAlias: "finding-account",
|
||||
providerUid: "finding-provider-uid",
|
||||
resourceName: "finding-resource-name",
|
||||
resourceUid: "finding-resource-uid",
|
||||
resourceService: "finding-service",
|
||||
resourceRegion: "ap-south-1",
|
||||
resourceType: "finding-type",
|
||||
resourceGroup: "finding-group",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentResource={currentResource}
|
||||
currentFinding={fetchedFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("row-service")).toBeInTheDocument();
|
||||
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-group")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-type")).toBeInTheDocument();
|
||||
expect(screen.getByText("FAIL")).toBeInTheDocument();
|
||||
expect(screen.getByText("critical")).toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-service")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ap-south-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-group")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("finding-type")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prefer the fetched finding status and severity in the header when the current row is stale", () => {
|
||||
// Given
|
||||
const currentResource: FindingResourceRow = {
|
||||
...mockResourceRow,
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
isMuted: false,
|
||||
};
|
||||
const fetchedFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
severity: "low",
|
||||
status: "PASS",
|
||||
isMuted: true,
|
||||
mutedReason: "Muted after refresh",
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentResource={currentResource}
|
||||
currentFinding={fetchedFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("PASS")).toBeInTheDocument();
|
||||
expect(screen.getByText("low")).toBeInTheDocument();
|
||||
expect(screen.queryByText("FAIL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("critical")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — header skeleton while navigating", () => {
|
||||
it("should keep row-backed navigation chrome visible while hiding stale finding details during carousel navigation", () => {
|
||||
// Given
|
||||
const currentResource: FindingResourceRow = {
|
||||
...mockResourceRow,
|
||||
checkId: mockCheckMeta.checkId,
|
||||
resourceName: "next-bucket",
|
||||
resourceUid: "next-resource-uid",
|
||||
service: "ec2",
|
||||
region: "eu-west-1",
|
||||
resourceType: "Instance",
|
||||
resourceGroup: "row-group",
|
||||
severity: "low",
|
||||
status: "PASS",
|
||||
findingId: "finding-2",
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={currentResource}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("PASS")).toBeInTheDocument();
|
||||
expect(screen.getByText("low")).toBeInTheDocument();
|
||||
expect(screen.getByText("ec2")).toBeInTheDocument();
|
||||
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("row-group")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Finding Overview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Status extended")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("FAIL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("critical")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should skeletonize stale check-level header content when navigating to a different check", () => {
|
||||
// Given
|
||||
const currentResource: FindingResourceRow = {
|
||||
...mockResourceRow,
|
||||
checkId: "ec2_check",
|
||||
findingId: "finding-2",
|
||||
severity: "low",
|
||||
status: "PASS",
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={currentResource}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("drawer-header-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByText("S3 Check")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("PCI-DSS")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("PASS")).toBeInTheDocument();
|
||||
expect(screen.getByText("low")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep same-check overview sections visible while hiding stale finding-specific details during navigation", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Risk:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remediation:")).toBeInTheDocument();
|
||||
expect(screen.getByText("security")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Analyze This Finding With Lighthouse AI",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => {
|
||||
// Given
|
||||
const currentResource: FindingResourceRow = {
|
||||
...mockResourceRow,
|
||||
checkId: "ec2_check",
|
||||
findingId: "finding-2",
|
||||
severity: "low",
|
||||
status: "PASS",
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={currentResource}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByTestId("overview-navigation-skeleton"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Risk:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Description:")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Remediation:")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Finding Overview" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Other Findings For This Resource" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep other findings table headers visible while skeletonizing only the rows during navigation", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Finding")).toBeInTheDocument();
|
||||
expect(screen.getByText("Severity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Time")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("other-findings-total-entries-skeleton"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("other-findings-navigation-skeleton"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep scans labels visible while skeletonizing only the scan values during navigation", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByText("Showing the latest scan that evaluated this finding"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Scan Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Resources Scanned")).toBeInTheDocument();
|
||||
expect(screen.getByText("Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("Trigger")).toBeInTheDocument();
|
||||
expect(screen.getByText("State")).toBeInTheDocument();
|
||||
expect(screen.getByText("Duration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Started At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Launched At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Scheduled At")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scans-navigation-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the events tab shell visible while showing timeline row skeletons during navigation", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={mockResourceRow}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Events" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("events-navigation-skeleton"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+556
-317
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -23,8 +24,10 @@ interface ResourceDetailDrawerProps {
|
||||
checkMeta: CheckMeta | null;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentResource: FindingResourceRow | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
showSyntheticResourceHint?: boolean;
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
@@ -38,8 +41,10 @@ export function ResourceDetailDrawer({
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentResource,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
showSyntheticResourceHint = false,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
@@ -64,8 +69,10 @@ export function ResourceDetailDrawer({
|
||||
checkMeta={checkMeta}
|
||||
currentIndex={currentIndex}
|
||||
totalResources={totalResources}
|
||||
currentResource={currentResource}
|
||||
currentFinding={currentFinding}
|
||||
otherFindings={otherFindings}
|
||||
showSyntheticResourceHint={showSyntheticResourceHint}
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
onMuteComplete={onMuteComplete}
|
||||
|
||||
+393
-98
@@ -6,14 +6,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
getFindingByIdMock,
|
||||
getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getFindingByIdMock: vi.fn(),
|
||||
getLatestFindingsByResourceUidMock: vi.fn(),
|
||||
adaptFindingsByResourceResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getFindingById: getFindingByIdMock,
|
||||
getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
|
||||
}));
|
||||
@@ -109,6 +112,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should abort the in-flight fetch controller when the hook unmounts", async () => {
|
||||
@@ -116,9 +120,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
// never-resolving fetch to simulate in-flight request
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
getFindingByIdMock.mockImplementation(() => new Promise(() => {}));
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([]);
|
||||
|
||||
const resources = [makeResource()];
|
||||
@@ -126,7 +128,6 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -136,7 +137,7 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
});
|
||||
|
||||
// Verify a fetch was started
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(1);
|
||||
expect(getFindingByIdMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset spy count to detect only the unmount abort
|
||||
abortSpy.mockClear();
|
||||
@@ -158,7 +159,6 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -173,40 +173,63 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("should exclude the current finding from otherFindings and preserve API order", async () => {
|
||||
it("should load other findings from the current resource uid and exclude the current finding", async () => {
|
||||
const resources = [makeResource()];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([
|
||||
makeDrawerFinding({
|
||||
id: "current",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "Current",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-1",
|
||||
checkId: "check-other-1",
|
||||
checkTitle: "Other 1",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-2",
|
||||
checkId: "check-other-2",
|
||||
checkTitle: "Other 2",
|
||||
status: "FAIL",
|
||||
severity: "medium",
|
||||
}),
|
||||
]);
|
||||
// Given
|
||||
getFindingByIdMock.mockResolvedValue({ data: ["detail"] });
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({
|
||||
data: ["resource"],
|
||||
});
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => {
|
||||
if (response.data[0] === "detail") {
|
||||
return [
|
||||
makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "Current",
|
||||
status: "MANUAL",
|
||||
severity: "informational",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
makeDrawerFinding({
|
||||
id: "finding-3",
|
||||
checkTitle: "First other finding",
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
checkTitle: "Current finding duplicate from resource fetch",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "finding-4",
|
||||
checkTitle: "Manual finding should be filtered out",
|
||||
status: "MANUAL",
|
||||
severity: "low",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "finding-5",
|
||||
checkTitle: "Second other finding",
|
||||
status: "FAIL",
|
||||
severity: "medium",
|
||||
}),
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -215,59 +238,65 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(getFindingByIdMock).toHaveBeenCalledWith(
|
||||
"finding-1",
|
||||
"resources,scan.provider",
|
||||
{ source: "resource-detail-drawer" },
|
||||
);
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
pageSize: 50,
|
||||
includeMuted: false,
|
||||
});
|
||||
expect(result.current.currentFinding?.id).toBe("finding-1");
|
||||
expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([
|
||||
"other-1",
|
||||
"other-2",
|
||||
"finding-3",
|
||||
"finding-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude non-FAIL findings from otherFindings", async () => {
|
||||
const resources = [makeResource()];
|
||||
it("should skip loading other findings for synthetic IaC resources and keep the current detail on findingId", async () => {
|
||||
const resources = [
|
||||
makeResource({
|
||||
findingId: "synthetic-finding",
|
||||
resourceUid: "synthetic://iac-resource",
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
// Given
|
||||
getFindingByIdMock.mockResolvedValue({ data: ["detail"] });
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([
|
||||
makeDrawerFinding({
|
||||
id: "current",
|
||||
id: "synthetic-finding",
|
||||
checkId: "s3_check",
|
||||
status: "MANUAL",
|
||||
severity: "informational",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-pass",
|
||||
checkId: "check-pass",
|
||||
status: "PASS",
|
||||
severity: "low",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-manual",
|
||||
checkId: "check-manual",
|
||||
status: "MANUAL",
|
||||
severity: "low",
|
||||
}),
|
||||
makeDrawerFinding({
|
||||
id: "other-fail",
|
||||
checkId: "check-fail",
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
canLoadOtherFindings: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
// When
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.currentFinding?.id).toBe("current");
|
||||
expect(result.current.otherFindings.map((f) => f.id)).toEqual([
|
||||
"other-fail",
|
||||
]);
|
||||
// Then
|
||||
expect(getFindingByIdMock).toHaveBeenCalledWith(
|
||||
"synthetic-finding",
|
||||
"resources,scan.provider",
|
||||
{ source: "resource-detail-drawer" },
|
||||
);
|
||||
expect(getLatestFindingsByResourceUidMock).not.toHaveBeenCalled();
|
||||
expect(result.current.currentFinding?.id).toBe("synthetic-finding");
|
||||
expect(result.current.otherFindings).toEqual([]);
|
||||
});
|
||||
|
||||
it("should request muted findings only when explicitly enabled", async () => {
|
||||
@@ -279,7 +308,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
includeMutedInOtherFindings: true,
|
||||
}),
|
||||
);
|
||||
@@ -291,6 +319,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
pageSize: 50,
|
||||
includeMuted: true,
|
||||
});
|
||||
});
|
||||
@@ -313,19 +342,19 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => ({
|
||||
data: [resourceUid],
|
||||
}),
|
||||
);
|
||||
getFindingByIdMock.mockImplementation(async (findingId: string) => ({
|
||||
data: [findingId],
|
||||
}));
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
id: response.data[0],
|
||||
resourceUid:
|
||||
response.data[0] === "finding-1"
|
||||
? "arn:aws:s3:::first-bucket"
|
||||
: "arn:aws:s3:::second-bucket",
|
||||
resourceName:
|
||||
response.data[0] === "finding-1" ? "first-bucket" : "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
@@ -333,7 +362,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -358,6 +386,8 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
expect(result.current.isNavigating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
vi.runAllTimers();
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -387,19 +417,19 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => ({
|
||||
data: [resourceUid],
|
||||
}),
|
||||
);
|
||||
getFindingByIdMock.mockImplementation(async (findingId: string) => ({
|
||||
data: [findingId],
|
||||
}));
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
id: response.data[0],
|
||||
resourceUid:
|
||||
response.data[0] === "finding-1"
|
||||
? "arn:aws:s3:::first-bucket"
|
||||
: "arn:aws:s3:::second-bucket",
|
||||
resourceName:
|
||||
response.data[0] === "finding-1" ? "first-bucket" : "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
@@ -407,7 +437,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -452,6 +481,154 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should update checkMeta when navigating to a resource with a different check", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
checkId: "ec2_check",
|
||||
resourceUid: "arn:aws:ec2:::instance/i-123",
|
||||
resourceName: "instance-1",
|
||||
service: "ec2",
|
||||
}),
|
||||
];
|
||||
|
||||
getFindingByIdMock.mockImplementation(async (findingId: string) => ({
|
||||
data: [findingId],
|
||||
}));
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
response.data[0] === "finding-1"
|
||||
? makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
description: "s3 description",
|
||||
})
|
||||
: makeDrawerFinding({
|
||||
id: "finding-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
description: "ec2 description",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("S3 Check");
|
||||
|
||||
await act(async () => {
|
||||
result.current.navigateNext();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check");
|
||||
expect(result.current.checkMeta?.description).toBe("ec2 description");
|
||||
});
|
||||
|
||||
it("should keep the previous check metadata cached while reopening until the new finding arrives", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
checkId: "ec2_check",
|
||||
resourceUid: "arn:aws:ec2:::instance/i-123",
|
||||
resourceName: "instance-1",
|
||||
service: "ec2",
|
||||
}),
|
||||
];
|
||||
|
||||
let resolveSecondFinding: ((value: { data: string[] }) => void) | null =
|
||||
null;
|
||||
|
||||
getFindingByIdMock.mockImplementation((findingId: string) => {
|
||||
if (findingId === "finding-2") {
|
||||
return new Promise((resolve) => {
|
||||
resolveSecondFinding = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: [findingId] });
|
||||
});
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
response.data[0] === "finding-1"
|
||||
? makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
description: "s3 description",
|
||||
})
|
||||
: makeDrawerFinding({
|
||||
id: "finding-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
description: "ec2 description",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("S3 Check");
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.closeDrawer();
|
||||
result.current.openDrawer(1);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.currentFinding).toBeNull();
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("S3 Check");
|
||||
|
||||
await act(async () => {
|
||||
resolveSecondFinding?.({ data: ["finding-2"] });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check");
|
||||
expect(result.current.checkMeta?.description).toBe("ec2 description");
|
||||
});
|
||||
|
||||
it("should clear the previous resource findings when navigation to the next resource fails", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
@@ -469,24 +646,24 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
async ({ resourceUid }: { resourceUid: string }) => {
|
||||
if (resourceUid.includes("second")) {
|
||||
throw new Error("Fetch failed");
|
||||
}
|
||||
getFindingByIdMock.mockImplementation(async (findingId: string) => {
|
||||
if (findingId === "finding-2") {
|
||||
throw new Error("Fetch failed");
|
||||
}
|
||||
|
||||
return { data: [resourceUid] };
|
||||
},
|
||||
);
|
||||
return { data: [findingId] };
|
||||
});
|
||||
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => [
|
||||
makeDrawerFinding({
|
||||
id: response.data[0].includes("first") ? "finding-1" : "finding-2",
|
||||
resourceUid: response.data[0],
|
||||
resourceName: response.data[0].includes("first")
|
||||
? "first-bucket"
|
||||
: "second-bucket",
|
||||
id: response.data[0],
|
||||
resourceUid:
|
||||
response.data[0] === "finding-1"
|
||||
? "arn:aws:s3:::first-bucket"
|
||||
: "arn:aws:s3:::second-bucket",
|
||||
resourceName:
|
||||
response.data[0] === "finding-1" ? "first-bucket" : "second-bucket",
|
||||
}),
|
||||
],
|
||||
);
|
||||
@@ -494,7 +671,6 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -506,6 +682,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
expect(result.current.currentFinding?.resourceUid).toBe(
|
||||
"arn:aws:s3:::first-bucket",
|
||||
);
|
||||
expect(result.current.checkMeta?.checkTitle).toBe("S3 Check");
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
@@ -517,5 +694,123 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.currentFinding).toBeNull();
|
||||
expect(result.current.otherFindings).toEqual([]);
|
||||
expect(result.current.checkMeta).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear other findings immediately while the next resource is loading", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
makeResource({
|
||||
id: "row-1",
|
||||
findingId: "finding-1",
|
||||
resourceUid: "arn:aws:s3:::first-bucket",
|
||||
resourceName: "first-bucket",
|
||||
}),
|
||||
makeResource({
|
||||
id: "row-2",
|
||||
findingId: "finding-2",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
resourceName: "second-bucket",
|
||||
}),
|
||||
];
|
||||
|
||||
let resolveSecondFinding: ((value: { data: string[] }) => void) | null =
|
||||
null;
|
||||
let resolveSecondResource: ((value: { data: string[] }) => void) | null =
|
||||
null;
|
||||
|
||||
getFindingByIdMock.mockImplementation((findingId: string) => {
|
||||
if (findingId === "finding-2") {
|
||||
return new Promise((resolve) => {
|
||||
resolveSecondFinding = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: [findingId] });
|
||||
});
|
||||
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
({ resourceUid }: { resourceUid: string }) => {
|
||||
if (resourceUid === "arn:aws:s3:::second-bucket") {
|
||||
return new Promise((resolve) => {
|
||||
resolveSecondResource = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: ["resource-1"] });
|
||||
},
|
||||
);
|
||||
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) => {
|
||||
if (response.data[0] === "finding-1") {
|
||||
return [makeDrawerFinding({ id: "finding-1" })];
|
||||
}
|
||||
|
||||
if (response.data[0] === "finding-2") {
|
||||
return [
|
||||
makeDrawerFinding({
|
||||
id: "finding-2",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
resourceName: "second-bucket",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (response.data[0] === "resource-1") {
|
||||
return [
|
||||
makeDrawerFinding({
|
||||
id: "finding-3",
|
||||
checkTitle: "First bucket other finding",
|
||||
resourceUid: "arn:aws:s3:::first-bucket",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
makeDrawerFinding({
|
||||
id: "finding-4",
|
||||
checkTitle: "Second bucket other finding",
|
||||
resourceUid: "arn:aws:s3:::second-bucket",
|
||||
}),
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([
|
||||
"finding-3",
|
||||
]);
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.navigateNext();
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
expect(result.current.currentFinding).toBeNull();
|
||||
expect(result.current.otherFindings).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
resolveSecondFinding?.({ data: ["finding-2"] });
|
||||
resolveSecondResource?.({ data: ["resource-2"] });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([
|
||||
"finding-4",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
getFindingById,
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
@@ -43,10 +44,10 @@ function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta {
|
||||
|
||||
interface UseResourceDetailDrawerOptions {
|
||||
resources: FindingResourceRow[];
|
||||
checkId: string;
|
||||
totalResourceCount?: number;
|
||||
onRequestMoreResources?: () => void;
|
||||
initialIndex?: number | null;
|
||||
canLoadOtherFindings?: boolean;
|
||||
includeMutedInOtherFindings?: boolean;
|
||||
}
|
||||
|
||||
@@ -57,9 +58,9 @@ interface UseResourceDetailDrawerReturn {
|
||||
checkMeta: CheckMeta | null;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentResource: FindingResourceRow | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
allFindings: ResourceDrawerFinding[];
|
||||
openDrawer: (index: number) => void;
|
||||
closeDrawer: () => void;
|
||||
navigatePrev: () => void;
|
||||
@@ -71,24 +72,33 @@ interface UseResourceDetailDrawerReturn {
|
||||
/**
|
||||
* Manages the resource detail drawer state, fetching, and navigation.
|
||||
*
|
||||
* Caches findings per resourceUid in a Map ref so navigating prev/next
|
||||
* Caches findings per findingId in a Map ref so navigating prev/next
|
||||
* doesn't re-fetch already-visited resources.
|
||||
*/
|
||||
export function useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId,
|
||||
totalResourceCount,
|
||||
onRequestMoreResources,
|
||||
initialIndex = null,
|
||||
canLoadOtherFindings = true,
|
||||
includeMutedInOtherFindings = false,
|
||||
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
|
||||
const [isOpen, setIsOpen] = useState(initialIndex !== null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0);
|
||||
const [findings, setFindings] = useState<ResourceDrawerFinding[]>([]);
|
||||
const [currentFinding, setCurrentFinding] =
|
||||
useState<ResourceDrawerFinding | null>(null);
|
||||
const [otherFindings, setOtherFindings] = useState<ResourceDrawerFinding[]>(
|
||||
[],
|
||||
);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
|
||||
const currentFindingCacheRef = useRef<
|
||||
Map<string, ResourceDrawerFinding | null>
|
||||
>(new Map());
|
||||
const otherFindingsCacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(
|
||||
new Map(),
|
||||
);
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
const navigationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
@@ -136,6 +146,11 @@ export function useResourceDetailDrawer({
|
||||
setIsNavigating(true);
|
||||
};
|
||||
|
||||
const resetCurrentResourceState = () => {
|
||||
setCurrentFinding(null);
|
||||
setOtherFindings([]);
|
||||
};
|
||||
|
||||
// Abort any in-flight request on unmount to prevent state updates
|
||||
// on an already-unmounted component.
|
||||
useEffect(() => {
|
||||
@@ -146,49 +161,83 @@ export function useResourceDetailDrawer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
const fetchFindings = async (resource: FindingResourceRow) => {
|
||||
// Abort any in-flight request to prevent stale data from out-of-order responses
|
||||
fetchControllerRef.current?.abort();
|
||||
clearNavigationTimeout();
|
||||
const controller = new AbortController();
|
||||
fetchControllerRef.current = controller;
|
||||
|
||||
// Check cache first
|
||||
const cached = cacheRef.current.get(resourceUid);
|
||||
if (cached) {
|
||||
if (!checkMetaRef.current) {
|
||||
const main = cached.find((f) => f.checkId === checkId) ?? cached[0];
|
||||
if (main) checkMetaRef.current = extractCheckMeta(main);
|
||||
const { findingId, resourceUid } = resource;
|
||||
|
||||
const fetchCurrentFinding = async () => {
|
||||
const cached = currentFindingCacheRef.current.get(findingId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
setFindings(cached);
|
||||
finishNavigation();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getFindingById(
|
||||
findingId,
|
||||
"resources,scan.provider",
|
||||
{ source: "resource-detail-drawer" },
|
||||
);
|
||||
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
const finding =
|
||||
adapted.find((item) => item.id === findingId) ?? adapted[0] ?? null;
|
||||
|
||||
currentFindingCacheRef.current.set(findingId, finding);
|
||||
|
||||
return finding;
|
||||
};
|
||||
|
||||
const fetchOtherFindings = async () => {
|
||||
if (!canLoadOtherFindings || !resourceUid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cached = otherFindingsCacheRef.current.get(resourceUid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await getLatestFindingsByResourceUid({
|
||||
resourceUid,
|
||||
pageSize: 50,
|
||||
includeMuted: includeMutedInOtherFindings,
|
||||
});
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
|
||||
otherFindingsCacheRef.current.set(resourceUid, adapted);
|
||||
|
||||
return adapted;
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getLatestFindingsByResourceUid({
|
||||
resourceUid,
|
||||
includeMuted: includeMutedInOtherFindings,
|
||||
});
|
||||
const [nextCurrentFinding, nextOtherFindings] = await Promise.all([
|
||||
fetchCurrentFinding(),
|
||||
fetchOtherFindings(),
|
||||
]);
|
||||
|
||||
// Discard stale response if a newer request was started
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
cacheRef.current.set(resourceUid, adapted);
|
||||
checkMetaRef.current = nextCurrentFinding
|
||||
? extractCheckMeta(nextCurrentFinding)
|
||||
: null;
|
||||
|
||||
// Extract check-level metadata once (stable across all resources)
|
||||
if (!checkMetaRef.current) {
|
||||
const main = adapted.find((f) => f.checkId === checkId) ?? adapted[0];
|
||||
if (main) checkMetaRef.current = extractCheckMeta(main);
|
||||
}
|
||||
|
||||
setFindings(adapted);
|
||||
} catch (error) {
|
||||
setCurrentFinding(nextCurrentFinding);
|
||||
setOtherFindings(
|
||||
nextOtherFindings.filter(
|
||||
(finding) => finding.id !== findingId && finding.status === "FAIL",
|
||||
),
|
||||
);
|
||||
} catch (_error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error("Error fetching findings for resource:", error);
|
||||
setFindings([]);
|
||||
checkMetaRef.current = null;
|
||||
setCurrentFinding(null);
|
||||
setOtherFindings([]);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -207,7 +256,7 @@ export function useResourceDetailDrawer({
|
||||
return;
|
||||
}
|
||||
|
||||
fetchFindings(resource.resourceUid);
|
||||
fetchFindings(resource);
|
||||
// Only initialize once on mount for deep-link/inline entry points.
|
||||
// User-driven navigations use openDrawer/navigateTo afterwards.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -217,13 +266,11 @@ export function useResourceDetailDrawer({
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
clearNavigationTimeout();
|
||||
navigationStartedAtRef.current = null;
|
||||
setCurrentIndex(index);
|
||||
setIsOpen(true);
|
||||
setIsNavigating(false);
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
startNavigation();
|
||||
resetCurrentResourceState();
|
||||
fetchFindings(resource);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
@@ -233,10 +280,11 @@ export function useResourceDetailDrawer({
|
||||
const refetchCurrent = () => {
|
||||
const resource = resources[currentIndex];
|
||||
if (!resource) return;
|
||||
cacheRef.current.delete(resource.resourceUid);
|
||||
currentFindingCacheRef.current.delete(resource.findingId);
|
||||
otherFindingsCacheRef.current.delete(resource.resourceUid);
|
||||
startNavigation();
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
resetCurrentResourceState();
|
||||
fetchFindings(resource);
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
@@ -245,8 +293,8 @@ export function useResourceDetailDrawer({
|
||||
|
||||
setCurrentIndex(index);
|
||||
startNavigation();
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
resetCurrentResourceState();
|
||||
fetchFindings(resource);
|
||||
};
|
||||
|
||||
const navigatePrev = () => {
|
||||
@@ -270,17 +318,7 @@ export function useResourceDetailDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
// The finding whose checkId matches the drill-down's checkId
|
||||
const currentFinding =
|
||||
findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null;
|
||||
|
||||
// "Other Findings For This Resource" intentionally shows only FAIL entries,
|
||||
// while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…).
|
||||
const otherFindings = (
|
||||
currentFinding
|
||||
? findings.filter((f) => f.id !== currentFinding.id)
|
||||
: findings
|
||||
).filter((f) => f.status === "FAIL");
|
||||
const currentResource = resources[currentIndex];
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
@@ -289,9 +327,9 @@ export function useResourceDetailDrawer({
|
||||
checkMeta: checkMetaRef.current,
|
||||
currentIndex,
|
||||
totalResources: totalResourceCount ?? resources.length,
|
||||
currentResource: currentResource ?? null,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
allFindings: findings,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
navigatePrev,
|
||||
|
||||
@@ -80,9 +80,9 @@ export function useFindingGroupResourceState({
|
||||
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: totalCount ?? group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
canLoadOtherFindings: group.resourcesTotal !== 0,
|
||||
includeMutedInOtherFindings: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import {
|
||||
canDrillDownFindingGroup,
|
||||
getActiveStatusFilter,
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupDelta,
|
||||
getFindingGroupImpactedCounts,
|
||||
isFindingGroupMuted,
|
||||
} from "./findings-groups";
|
||||
|
||||
@@ -138,6 +140,119 @@ describe("getActiveStatusFilter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFindingGroupImpactedCounts", () => {
|
||||
it("should fall back to pass and fail counts when resources total is zero", () => {
|
||||
// Given
|
||||
const group = makeGroup({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 3,
|
||||
passCount: 2,
|
||||
muted: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = getFindingGroupImpactedCounts(group);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ impacted: 3, total: 5 });
|
||||
});
|
||||
|
||||
it("should include manual findings in fallback counts when resources total is zero", () => {
|
||||
// Given
|
||||
const group = makeGroup({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 3,
|
||||
passCount: 2,
|
||||
manualCount: 4,
|
||||
muted: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = getFindingGroupImpactedCounts(group);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ impacted: 3, total: 9 });
|
||||
});
|
||||
|
||||
it("should include muted pass and fail counts in the denominator when the result is muted", () => {
|
||||
// Given
|
||||
const group = makeGroup({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 3,
|
||||
passCount: 2,
|
||||
failMutedCount: 4,
|
||||
passMutedCount: 1,
|
||||
muted: true,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = getFindingGroupImpactedCounts(group);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ impacted: 3, total: 10 });
|
||||
});
|
||||
|
||||
it("should keep resource-based counts when resources total is available", () => {
|
||||
// Given
|
||||
const group = makeGroup({
|
||||
resourcesTotal: 6,
|
||||
resourcesFail: 4,
|
||||
failCount: 2,
|
||||
passCount: 1,
|
||||
failMutedCount: 5,
|
||||
passMutedCount: 3,
|
||||
muted: true,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = getFindingGroupImpactedCounts(group);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ impacted: 4, total: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDrillDownFindingGroup", () => {
|
||||
it("should allow drill-down when resources exist", () => {
|
||||
expect(
|
||||
canDrillDownFindingGroup(
|
||||
makeGroup({
|
||||
resourcesTotal: 2,
|
||||
failCount: 0,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep zero-resource fallback groups non-expandable even when fallback counts are present", () => {
|
||||
expect(
|
||||
canDrillDownFindingGroup(
|
||||
makeGroup({
|
||||
resourcesTotal: 0,
|
||||
failCount: 0,
|
||||
passCount: 2,
|
||||
manualCount: 1,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep drill-down disabled for zero-resource groups when the displayed total is zero", () => {
|
||||
expect(
|
||||
canDrillDownFindingGroup(
|
||||
makeGroup({
|
||||
resourcesTotal: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFilteredFindingGroupDelta", () => {
|
||||
it("falls back to the aggregate delta when no status filter is active", () => {
|
||||
expect(
|
||||
|
||||
+81
-24
@@ -1,9 +1,10 @@
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
type FindingGroupMutedState = Pick<
|
||||
FindingGroupRow,
|
||||
"muted" | "mutedCount" | "resourcesFail" | "resourcesTotal"
|
||||
>;
|
||||
import {
|
||||
FINDING_DELTA,
|
||||
FINDING_STATUS,
|
||||
type FindingDelta,
|
||||
type FindingGroupRow,
|
||||
type FindingStatus,
|
||||
} from "@/types";
|
||||
|
||||
type FindingGroupDeltaState = Pick<
|
||||
FindingGroupRow,
|
||||
@@ -23,7 +24,18 @@ type FindingGroupDeltaState = Pick<
|
||||
| "changedManualMutedCount"
|
||||
>;
|
||||
|
||||
export function isFindingGroupMuted(group: FindingGroupMutedState): boolean {
|
||||
type FindingGroupDelta = Exclude<FindingDelta, null>;
|
||||
|
||||
type FindingGroupStatus = FindingStatus;
|
||||
|
||||
const FINDING_GROUP_STATUSES = Object.values(FINDING_STATUS);
|
||||
|
||||
export function isFindingGroupMuted(
|
||||
group: Pick<
|
||||
FindingGroupRow,
|
||||
"muted" | "mutedCount" | "resourcesFail" | "resourcesTotal"
|
||||
>,
|
||||
): boolean {
|
||||
if (typeof group.muted === "boolean") {
|
||||
return group.muted;
|
||||
}
|
||||
@@ -38,6 +50,54 @@ export function isFindingGroupMuted(group: FindingGroupMutedState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function getFindingGroupImpactedCounts(
|
||||
group: Pick<
|
||||
FindingGroupRow,
|
||||
| "resourcesTotal"
|
||||
| "resourcesFail"
|
||||
| "passCount"
|
||||
| "failCount"
|
||||
| "manualCount"
|
||||
| "passMutedCount"
|
||||
| "failMutedCount"
|
||||
| "manualMutedCount"
|
||||
| "muted"
|
||||
| "mutedCount"
|
||||
>,
|
||||
): { impacted: number; total: number } {
|
||||
if (group.resourcesTotal > 0) {
|
||||
return {
|
||||
impacted: group.resourcesFail,
|
||||
total: group.resourcesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
const total =
|
||||
(group.passCount ?? 0) + (group.failCount ?? 0) + (group.manualCount ?? 0);
|
||||
|
||||
if (!isFindingGroupMuted(group)) {
|
||||
return {
|
||||
impacted: group.failCount ?? 0,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
impacted: group.failCount ?? 0,
|
||||
total:
|
||||
total +
|
||||
(group.passMutedCount ?? 0) +
|
||||
(group.failMutedCount ?? 0) +
|
||||
(group.manualMutedCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function canDrillDownFindingGroup(
|
||||
group: Pick<FindingGroupRow, "resourcesTotal">,
|
||||
): boolean {
|
||||
return group.resourcesTotal > 0;
|
||||
}
|
||||
|
||||
function getNewDeltaTotal(group: FindingGroupDeltaState): number {
|
||||
const breakdownTotal =
|
||||
(group.newFailCount ?? 0) +
|
||||
@@ -64,21 +124,18 @@ function getChangedDeltaTotal(group: FindingGroupDeltaState): number {
|
||||
|
||||
export function getFindingGroupDelta(
|
||||
group: FindingGroupDeltaState,
|
||||
): "new" | "changed" | "none" {
|
||||
): FindingGroupDelta {
|
||||
if (getNewDeltaTotal(group) > 0) {
|
||||
return "new";
|
||||
return FINDING_DELTA.NEW;
|
||||
}
|
||||
|
||||
if (getChangedDeltaTotal(group) > 0) {
|
||||
return "changed";
|
||||
return FINDING_DELTA.CHANGED;
|
||||
}
|
||||
|
||||
return "none";
|
||||
return FINDING_DELTA.NONE;
|
||||
}
|
||||
|
||||
const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const;
|
||||
type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number];
|
||||
|
||||
type FindingGroupFiltersRecord = Record<string, string | string[] | undefined>;
|
||||
|
||||
function parseStatusFilterValue(
|
||||
@@ -142,13 +199,13 @@ function getNewDeltaForStatuses(
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
if (statuses.has(FINDING_STATUS.FAIL)) {
|
||||
total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
if (statuses.has(FINDING_STATUS.PASS)) {
|
||||
total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
if (statuses.has(FINDING_STATUS.MANUAL)) {
|
||||
total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0);
|
||||
}
|
||||
return total;
|
||||
@@ -159,13 +216,13 @@ function getChangedDeltaForStatuses(
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
if (statuses.has(FINDING_STATUS.FAIL)) {
|
||||
total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
if (statuses.has(FINDING_STATUS.PASS)) {
|
||||
total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
if (statuses.has(FINDING_STATUS.MANUAL)) {
|
||||
total +=
|
||||
(group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0);
|
||||
}
|
||||
@@ -182,7 +239,7 @@ function getChangedDeltaForStatuses(
|
||||
export function getFilteredFindingGroupDelta(
|
||||
group: FindingGroupDeltaState,
|
||||
filters: FindingGroupFiltersRecord,
|
||||
): "new" | "changed" | "none" {
|
||||
): FindingGroupDelta {
|
||||
const activeStatuses = getActiveStatusFilter(filters);
|
||||
|
||||
if (!activeStatuses || !hasAnyDeltaBreakdown(group)) {
|
||||
@@ -190,12 +247,12 @@ export function getFilteredFindingGroupDelta(
|
||||
}
|
||||
|
||||
if (getNewDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "new";
|
||||
return FINDING_DELTA.NEW;
|
||||
}
|
||||
|
||||
if (getChangedDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "changed";
|
||||
return FINDING_DELTA.CHANGED;
|
||||
}
|
||||
|
||||
return "none";
|
||||
return FINDING_DELTA.NONE;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export type PermissionState =
|
||||
export const FINDING_DELTA = {
|
||||
NEW: "new",
|
||||
CHANGED: "changed",
|
||||
NONE: "none",
|
||||
} as const;
|
||||
export type FindingDelta =
|
||||
| (typeof FINDING_DELTA)[keyof typeof FINDING_DELTA]
|
||||
|
||||
Reference in New Issue
Block a user