diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index a1520588d0..f320c2ec2b 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -8,16 +8,9 @@ import type { JiraDispatchMode, JiraDispatchRequest, JiraDispatchResponse, + JiraDispatchTarget, } from "@/types/integrations"; -import { JIRA_DISPATCH_MODE } from "@/types/integrations"; - -const JIRA_DISPATCH_FILTER = { - CHECK_ID: "check_id", - FINDING_ID: "finding_id", -} as const; - -type JiraDispatchFilter = - (typeof JIRA_DISPATCH_FILTER)[keyof typeof JIRA_DISPATCH_FILTER]; +import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; type JiraTaskResult = NonNullable< JiraDispatchResponse["data"]["attributes"]["result"] @@ -26,7 +19,7 @@ type JiraTaskResult = NonNullable< interface JiraDispatchInput { integrationId: string; targetIds: string[]; - filter: JiraDispatchFilter; + filter: JiraDispatchTarget; projectKey: string; issueType: string; dispatchMode?: JiraDispatchMode; @@ -150,7 +143,7 @@ export const sendFindingToJira = async ( return sendJiraDispatch({ integrationId, targetIds: [findingId], - filter: JIRA_DISPATCH_FILTER.FINDING_ID, + filter: JIRA_DISPATCH_TARGET.FINDING_ID, projectKey, issueType, }); diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 4ef8a63827..31d3ae76c2 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -47,7 +47,13 @@ describe("findings page", () => { it("loads finding groups as selectable Finding Group filter options", () => { expect(source).toContain("fetchFindingGroupFilterOptions"); - expect(source).toContain("pageSize: 100"); + expect(source).toContain("FINDING_GROUP_FILTER_OPTION_PAGE_SIZE"); expect(source).toContain("checkOptions={checkOptions}"); }); + + it("excludes Finding Group's own filters while loading all option pages", () => { + expect(source).toContain("excludeFindingGroupOwnFilters"); + expect(source).toContain('"filter[check_id__in]"'); + expect(source).toContain("page <= totalPages"); + }); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 9d976c6035..39e95ecb2b 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -29,6 +29,60 @@ import { isCloud } from "@/lib/shared/env"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; +const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100; +const FINDING_GROUP_OWN_FILTER_KEYS = [ + "filter[check_id]", + "filter[check_id__in]", +] as const; + +type FindingGroupFilterFetcher = typeof getFindingGroups; + +function excludeFindingGroupOwnFilters( + filters: Record, +) { + return Object.fromEntries( + Object.entries(filters).filter( + ([key]) => + !FINDING_GROUP_OWN_FILTER_KEYS.includes( + key as (typeof FINDING_GROUP_OWN_FILTER_KEYS)[number], + ), + ), + ); +} + +async function getFindingGroupFilterOptions({ + fetchFindingGroups, + filters, +}: { + fetchFindingGroups: FindingGroupFilterFetcher; + filters: Record; +}) { + const optionFilters = excludeFindingGroupOwnFilters(filters); + const options = new Map(); + let page = 1; + let totalPages = 1; + + do { + const response = await fetchFindingGroups({ + filters: optionFilters, + page, + pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE, + }); + + for (const group of adaptFindingGroupsResponse(response)) { + options.set(group.checkId, { + checkId: group.checkId, + checkTitle: group.checkTitle, + }); + } + + totalPages = response?.meta?.pagination?.pages ?? page; + page += 1; + } while (page <= totalPages); + + return Array.from(options.values()); +} + export default async function Findings({ searchParams, }: { @@ -71,16 +125,10 @@ export default async function Findings({ const fetchFindingGroupFilterOptions = hasHistoricalData ? getFindingGroups : getLatestFindingGroups; - const findingGroupFilterOptionsData = await fetchFindingGroupFilterOptions({ + const checkOptions = await getFindingGroupFilterOptions({ + fetchFindingGroups: fetchFindingGroupFilterOptions, filters: resolvedFilters, - pageSize: 100, }); - const checkOptions = adaptFindingGroupsResponse( - findingGroupFilterOptionsData, - ).map((group) => ({ - checkId: group.checkId, - checkTitle: group.checkTitle, - })); const completedScans = scansData?.data?.filter( (scan: ScanProps) => diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 101dcbfa84..5b0fe99cb2 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -408,7 +408,7 @@ describe("buildFindingGroupFilterOption", () => { // Then expect(filter).toMatchObject({ - key: "check_id", + key: "check_id__in", labelCheckboxGroup: "Finding Group", values: ["teams_external_users_can_join", "s3_bucket_public_access"], index: 3, diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 7039c4ccf4..09188e65e6 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -139,7 +139,7 @@ export function buildFindingGroupFilterOption({ } return { - key: "check_id", + key: "check_id__in", labelCheckboxGroup: "Finding Group", values, labelFormatter: (value: string) => diff --git a/ui/components/findings/floating-mute-button.test.tsx b/ui/components/findings/floating-mute-button.test.tsx index 3cc6f36634..5c26056ef6 100644 --- a/ui/components/findings/floating-mute-button.test.tsx +++ b/ui/components/findings/floating-mute-button.test.tsx @@ -61,7 +61,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When — click the button (triggers onBeforeOpen which rejects) await user.click(button); - await user.click(screen.getByRole("button", { name: "Mute" })); // Then — button should NOT be disabled (isResolving reset to false) await waitFor(() => { @@ -84,7 +83,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When await user.click(screen.getByRole("button")); - await user.click(screen.getByRole("button", { name: "Mute" })); // Then await waitFor(() => { @@ -124,7 +122,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When await user.click(screen.getByRole("button")); - await user.click(screen.getByRole("button", { name: "Mute" })); // Then — modal opened (MuteFindingsModal called with isOpen=true) await waitFor(() => { @@ -159,7 +156,6 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { // When await user.click(screen.getByRole("button")); - await user.click(screen.getByRole("button", { name: "Mute" })); // Then const preparingCall = ( @@ -219,7 +215,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { ); // When - await user.click(screen.getByRole("button", { name: "Mute (1)" })); + await user.click(screen.getByRole("button", { name: "1 selected" })); await user.click(screen.getByRole("button", { name: "Send to Jira" })); // Then @@ -281,7 +277,7 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => { ); // When - await user.click(screen.getByRole("button", { name: "Mute (1)" })); + await user.click(screen.getByRole("button", { name: "1 selected" })); const jiraButton = screen.getByRole("button", { name: "Send to Jira" }); // Then diff --git a/ui/components/findings/floating-mute-button.tsx b/ui/components/findings/floating-mute-button.tsx index ac24e3d6ff..1a50e84676 100644 --- a/ui/components/findings/floating-mute-button.tsx +++ b/ui/components/findings/floating-mute-button.tsx @@ -14,6 +14,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { PROWLER_CLOUD_ONLY_TOOLTIP } from "@/lib/deployment"; import { cn } from "@/lib/utils"; import { MuteFindingsModal } from "./mute-findings-modal"; @@ -42,8 +43,6 @@ interface FloatingMuteButtonProps { jiraDisabledTooltip?: string; } -export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud"; - const CloudFeatureBadgeLink = () => ( { + if (hasMultipleActions) { + setIsActionChooserOpen(true); + return; + } + + void handleMuteClick(); + }; return ( <> @@ -205,7 +213,7 @@ export function FloatingMuteButton({ ? createPortal(
, document.body, diff --git a/ui/components/findings/send-to-jira-modal.test.tsx b/ui/components/findings/send-to-jira-modal.test.tsx index aeb4925ffc..a064ddae77 100644 --- a/ui/components/findings/send-to-jira-modal.test.tsx +++ b/ui/components/findings/send-to-jira-modal.test.tsx @@ -483,7 +483,7 @@ describe("SendToJiraModal", () => { ); }); - it("shows an error toast when one mixed dispatch batch fails", async () => { + it("shows a partial success toast when one mixed dispatch batch fails after another succeeds", async () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); @@ -542,10 +542,9 @@ describe("SendToJiraModal", () => { // Then await waitFor(() => expect(toastMock).toHaveBeenCalledWith({ - variant: "destructive", - title: "Error", + title: "Partial success", description: - "Jira dispatch completed with 1 failed and 1 created issue.", + "Finding successfully sent to Jira! Some Jira dispatches failed: Jira dispatch completed with 1 failed and 1 created issue.", }), ); expect(toastMock).not.toHaveBeenCalledWith( @@ -605,9 +604,9 @@ describe("SendToJiraModal", () => { ); await waitFor(() => expect(toastMock).toHaveBeenCalledWith({ - variant: "destructive", - title: "Error", - description: "Failed to launch Finding batch.", + title: "Partial success", + description: + "Finding successfully sent to Jira! Some Jira dispatches failed: Failed to launch Finding batch.", }), ); }); diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index 38ca59cde8..397d8d2d73 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -27,7 +27,9 @@ import { toast } from "@/components/shadcn/toast"; import { IntegrationProps, JIRA_DISPATCH_MODE, + JIRA_DISPATCH_TARGET, type JiraDispatchMode, + type JiraDispatchTarget, } from "@/types/integrations"; import { @@ -35,28 +37,17 @@ import { JIRA_SELECTION_KIND, } from "./send-to-jira-modal-copy"; -const JIRA_DISPATCH_TARGET = { - CHECK_ID: "check_id", - FINDING_ID: "finding_id", -} as const; - -type JiraDispatchTarget = - (typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET]; - interface JiraDispatchTargetBatch { targetIds: string[]; targetType: JiraDispatchTarget; dispatchMode?: JiraDispatchMode; } -interface SendToJiraModalProps { +interface SendToJiraModalBaseProps { isOpen: boolean; onOpenChange: (open: boolean) => void; findingId: string; findingTitle?: string; - targetIds?: string[]; - targetType?: JiraDispatchTarget; - targetBatches?: JiraDispatchTargetBatch[]; defaultDispatchMode?: JiraDispatchMode; canChooseGroupedDispatch?: boolean; isFindingGroupSelection?: boolean; @@ -64,6 +55,29 @@ interface SendToJiraModalProps { description?: string; } +interface SendToJiraSingleTargetProps extends SendToJiraModalBaseProps { + targetIds?: never; + targetType?: never; + targetBatches?: never; +} + +interface SendToJiraTargetListProps extends SendToJiraModalBaseProps { + targetIds?: string[]; + targetType?: JiraDispatchTarget; + targetBatches?: never; +} + +interface SendToJiraBatchProps extends SendToJiraModalBaseProps { + targetIds?: string[]; + targetType?: JiraDispatchTarget; + targetBatches?: JiraDispatchTargetBatch[]; +} + +type SendToJiraModalProps = + | SendToJiraSingleTargetProps + | SendToJiraTargetListProps + | SendToJiraBatchProps; + const sendToJiraSchema = z.object({ integration: z.string().min(1, "Please select a Jira integration"), project: z.string().min(1, "Please select a project"), @@ -217,7 +231,7 @@ export const SendToJiraModal = ({ form.reset(); setFetchedIssueTypes({}); } - }, [isOpen, form, toast]); + }, [isOpen, form]); useEffect(() => { if (isOpen) { @@ -281,6 +295,17 @@ export const SendToJiraModal = ({ ]; if (errors.length > 0) { + const successfulTask = taskResults.find( + (taskResult) => taskResult.success, + ); + if (successfulTask) { + toast({ + title: "Partial success", + description: `${successfulTask.message || "Some Jira issues were created successfully."} Some Jira dispatches failed: ${errors.join(" ")}`, + }); + return; + } + throw new Error(errors.join(" ")); } @@ -377,7 +402,6 @@ export const SendToJiraModal = ({ selectedProject, issueTypesFromConfig.length, fetchedIssueTypes, - toast, ]); const issueTypeOptions = issueTypesForProject.map((type) => ({ diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index ad9f6f144a..9cbc1794b1 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -181,6 +181,7 @@ vi.mock("@/lib/date-utils", () => ({ vi.mock("@/lib/deployment", () => ({ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); const notificationIndicatorMock = vi.fn((_props: unknown) => null); diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 3029c82bce..e93becb25f 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -19,7 +19,10 @@ import { Spinner } from "@/components/shadcn/spinner/spinner"; import { SeverityBadge } from "@/components/shadcn/table"; import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header"; import { getFailingForLabel } from "@/lib/date-utils"; -import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; import { FindingResourceRow } from "@/types"; import type { FindingTriageLoadedNote, @@ -27,7 +30,6 @@ import type { } from "@/types/findings-triage"; import { JIRA_DISPATCH_MODE } from "@/types/integrations"; -import { PROWLER_CLOUD_ONLY_TOOLTIP } from "../floating-mute-button"; import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingNoteActionItem, diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index ede42cce42..20798eecc2 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/components/findings/send-to-jira-modal", () => ({ vi.mock("@/lib/deployment", () => ({ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); vi.mock("@/components/icons/services/IconServices", () => ({ diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 4283d7655b..e2f92c04f9 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -13,7 +13,10 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Spinner } from "@/components/shadcn/spinner/spinner"; -import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; import { isFindingGroupMuted } from "@/lib/findings-groups"; import { getOptionalText } from "@/lib/utils"; import type { @@ -23,7 +26,6 @@ import type { import { JIRA_DISPATCH_MODE } from "@/types/integrations"; import type { ProviderType } from "@/types/providers"; -import { PROWLER_CLOUD_ONLY_TOOLTIP } from "../floating-mute-button"; import { canMuteFindingGroup } from "./finding-group-selection"; import type { FindingTriageContext } from "./finding-note-modal"; import { FindingNoteActionItem } from "./finding-triage-cells"; diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index cea759e5ce..751a361121 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -26,7 +26,10 @@ import { import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table"; import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { cn, hasHistoricalFindingFilter } from "@/lib"; -import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; import { getFilteredFindingGroupDelta, getFindingGroupImpactedCounts, @@ -35,10 +38,7 @@ import { import { FindingGroupRow } from "@/types"; import { JIRA_DISPATCH_MODE } from "@/types/integrations"; -import { - FloatingMuteButton, - PROWLER_CLOUD_ONLY_TOOLTIP, -} from "../floating-mute-button"; +import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 2baea20ca9..f43b57b662 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -122,6 +122,7 @@ vi.mock("@/actions/findings/findings-by-resource", () => ({ vi.mock("@/lib/deployment", () => ({ isGroupedJiraDispatchEnabled: isGroupedJiraDispatchEnabledMock, + PROWLER_CLOUD_ONLY_TOOLTIP: "Available only in Prowler Cloud", })); vi.mock("../send-to-jira-modal", () => ({ diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index d592cf7b7e..9c93fb99ed 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -9,17 +9,17 @@ import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbo import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { OnboardingTrigger, PageReady } from "@/components/onboarding"; import { DataTable } from "@/components/shadcn/table"; -import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; +import { + isGroupedJiraDispatchEnabled, + PROWLER_CLOUD_ONLY_TOOLTIP, +} from "@/lib/deployment"; import { canDrillDownFindingGroup } from "@/lib/findings-groups"; import { getFlowById } from "@/lib/onboarding"; import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour"; import { FindingGroupRow, MetaDataProps } from "@/types"; import { JIRA_DISPATCH_MODE } from "@/types/integrations"; -import { - FloatingMuteButton, - PROWLER_CLOUD_ONLY_TOOLTIP, -} from "../floating-mute-button"; +import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingGroups } from "./column-finding-groups"; import { canMuteFindingGroup } from "./finding-group-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; @@ -29,6 +29,7 @@ import { } from "./inline-resource-container"; const exploreFindingsFlow = getFlowById("explore-findings")!; +const EMPTY_FINDING_GROUPS: FindingGroupRow[] = []; function buildSelectionSummary( groupCount: number, @@ -105,7 +106,7 @@ export function FindingsGroupTable({ undefined, ); - const safeData = data ?? []; + const safeData = data ?? EMPTY_FINDING_GROUPS; const hasResourceSelection = resourceSelection.length > 0; const filters = resolvedFilters; const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled(); diff --git a/ui/lib/deployment.ts b/ui/lib/deployment.ts index 0cd4014551..2498f764ea 100644 --- a/ui/lib/deployment.ts +++ b/ui/lib/deployment.ts @@ -10,6 +10,8 @@ export const ENTERPRISE_FEATURE_ENV = { POSTHOG_ENABLED: "NEXT_PUBLIC_PROWLER_ENTERPRISE_POSTHOG_ENABLED", } as const; +export const PROWLER_CLOUD_ONLY_TOOLTIP = "Available only in Prowler Cloud"; + export type DeploymentMode = (typeof DEPLOYMENT_MODE)[keyof typeof DEPLOYMENT_MODE]; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 92c852d41d..0f358cf45f 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -12,6 +12,14 @@ export const JIRA_DISPATCH_MODE = { export type JiraDispatchMode = (typeof JIRA_DISPATCH_MODE)[keyof typeof JIRA_DISPATCH_MODE]; +export const JIRA_DISPATCH_TARGET = { + CHECK_ID: "check_id", + FINDING_ID: "finding_id", +} as const; + +export type JiraDispatchTarget = + (typeof JIRA_DISPATCH_TARGET)[keyof typeof JIRA_DISPATCH_TARGET]; + export interface IntegrationProps { type: "integrations"; id: string;