diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 530479921e..fc28673524 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -3,6 +3,66 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiResponse } from "@/lib/server-actions-helper"; +/** + * Resolves resource UIDs + check ID into actual finding UUIDs. + * Uses /findings/latest with check_id and resource_uid__in filters + * to batch-resolve in a single API call. + */ +export const resolveFindingIds = async ({ + checkId, + resourceUids, +}: { + checkId: string; + resourceUids: string[]; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/findings/latest`); + url.searchParams.append("filter[check_id]", checkId); + url.searchParams.append("filter[resource_uid__in]", resourceUids.join(",")); + url.searchParams.append("page[size]", resourceUids.length.toString()); + + try { + const response = await fetch(url.toString(), { headers }); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) return []; + + return data.data.map((item: { id: string }) => item.id); + } catch (error) { + console.error("Error resolving finding IDs:", error); + return []; + } +}; + +/** + * Resolves check IDs into actual finding UUIDs. + * Used at the group level where each row represents a check_id. + */ +export const resolveFindingIdsByCheckIds = async ({ + checkIds, +}: { + checkIds: string[]; +}): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/findings/latest`); + url.searchParams.append("filter[check_id__in]", checkIds.join(",")); + url.searchParams.append("page[size]", "500"); + + try { + const response = await fetch(url.toString(), { headers }); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) return []; + + return data.data.map((item: { id: string }) => item.id); + } catch (error) { + console.error("Error resolving finding IDs by check IDs:", error); + return []; + } +}; + export const getLatestFindingsByResourceUid = async ({ resourceUid, page = 1, diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index de33051001..6a4850c38b 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -48,8 +48,10 @@ function getFailingForLabel(firstSeenAt: string | null): string | null { const ResourceRowActions = ({ row }: { row: Row }) => { const resource = row.original; const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [resolvedIds, setResolvedIds] = useState([]); + const [isResolving, setIsResolving] = useState(false); - const { selectedFindingIds, clearSelection } = useContext( + const { selectedFindingIds, clearSelection, resolveMuteIds } = useContext( FindingsSelectionContext, ) || { selectedFindingIds: [], @@ -59,7 +61,7 @@ const ResourceRowActions = ({ row }: { row: Row }) => { const isCurrentSelected = selectedFindingIds.includes(resource.findingId); const hasMultipleSelected = selectedFindingIds.length > 1; - const getMuteIds = (): string[] => { + const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { return selectedFindingIds; } @@ -68,18 +70,38 @@ const ResourceRowActions = ({ row }: { row: Row }) => { const getMuteLabel = () => { if (resource.isMuted) return "Muted"; - const ids = getMuteIds(); + const ids = getDisplayIds(); if (ids.length > 1) return `Mute ${ids.length}`; return "Mute"; }; + const handleMuteClick = async () => { + const displayIds = getDisplayIds(); + + if (resolveMuteIds) { + setIsResolving(true); + const ids = await resolveMuteIds(displayIds); + setResolvedIds(ids); + setIsResolving(false); + if (ids.length > 0) setIsMuteModalOpen(true); + } else { + setResolvedIds(displayIds); + setIsMuteModalOpen(true); + } + }; + + const handleMuteComplete = () => { + clearSelection(); + setResolvedIds([]); + }; + return ( <>
}) => { icon={ resource.isMuted ? ( + ) : isResolving ? ( +
) : ( ) } - label={getMuteLabel()} - disabled={resource.isMuted} - onSelect={() => setIsMuteModalOpen(true)} + label={isResolving ? "Resolving..." : getMuteLabel()} + disabled={resource.isMuted || isResolving} + onSelect={handleMuteClick} />
diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index cf44bc2927..1076eaf199 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -68,41 +68,57 @@ export function DataTableRowActions({ // Get selection context - if there are other selected rows, include them const selectionContext = useContext(FindingsSelectionContext); - const { selectedFindingIds, clearSelection } = selectionContext || { - selectedFindingIds: [], - clearSelection: () => {}, - }; + const { selectedFindingIds, clearSelection, resolveMuteIds } = + selectionContext || { + selectedFindingIds: [], + clearSelection: () => {}, + }; + + const [resolvedIds, setResolvedIds] = useState([]); + const [isResolving, setIsResolving] = useState(false); // If current finding is selected and there are multiple selections, mute all // Otherwise, just mute this single finding const isCurrentSelected = selectedFindingIds.includes(finding.id); const hasMultipleSelected = selectedFindingIds.length > 1; - const getMuteIds = (): string[] => { + const getDisplayIds = (): string[] => { if (isCurrentSelected && hasMultipleSelected) { - // Mute all selected including current return selectedFindingIds; } - // Just mute the current finding return [finding.id]; }; const getMuteLabel = () => { if (isMuted) return "Muted"; - const ids = getMuteIds(); + const ids = getDisplayIds(); if (ids.length > 1) { return `Mute ${ids.length} Findings`; } return "Mute Finding"; }; + const handleMuteClick = async () => { + const displayIds = getDisplayIds(); + + if (resolveMuteIds) { + setIsResolving(true); + const ids = await resolveMuteIds(displayIds); + setResolvedIds(ids); + setIsResolving(false); + if (ids.length > 0) setIsMuteModalOpen(true); + } else { + // Regular findings — IDs are already valid finding UUIDs + setResolvedIds(displayIds); + setIsMuteModalOpen(true); + } + }; + const handleMuteComplete = () => { - // Always clear selection when a finding is muted because: - // 1. If the muted finding was selected, its index now points to a different finding - // 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected clearSelection(); + setResolvedIds([]); if (onMuteComplete) { - onMuteComplete(getMuteIds()); + onMuteComplete(getDisplayIds()); return; } @@ -121,7 +137,7 @@ export function DataTableRowActions({ @@ -145,15 +161,15 @@ export function DataTableRowActions({ icon={ isMuted ? ( + ) : isResolving ? ( +
) : ( ) } - label={getMuteLabel()} - disabled={isMuted} - onSelect={() => { - setIsMuteModalOpen(true); - }} + label={isResolving ? "Resolving..." : getMuteLabel()} + disabled={isMuted || isResolving} + onSelect={handleMuteClick} /> } diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 9234f34b23..275010c1ea 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -7,10 +7,12 @@ import { RowSelectionState, useReactTable, } from "@tanstack/react-table"; -import { ChevronLeft } from "lucide-react"; +import { ChevronLeft, VolumeX } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; +import { resolveFindingIds } from "@/actions/findings/findings-by-resource"; +import { Button } from "@/components/shadcn"; import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner"; import { Table, @@ -25,7 +27,7 @@ import { useInfiniteResources } from "@/hooks/use-infinite-resources"; import { cn, hasDateOrScanFilter } from "@/lib"; import { FindingGroupRow, FindingResourceRow } from "@/types"; -import { FloatingMuteButton } from "../floating-mute-button"; +import { MuteFindingsModal } from "../mute-findings-modal"; import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; @@ -108,12 +110,42 @@ export function FindingsGroupDrillDown({ router.refresh(); }; - // Selection logic for resources + // Selection logic — tracks by findingId (resource_id) for checkbox consistency const selectedFindingIds = Object.keys(rowSelection) .filter((key) => rowSelection[key]) .map((idx) => resources[parseInt(idx)]?.findingId) .filter(Boolean); + // Mute modal state — resource IDs resolved to finding UUIDs on-click + const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [resolvedFindingIds, setResolvedFindingIds] = useState([]); + const [isResolvingIds, setIsResolvingIds] = useState(false); + + /** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */ + const resolveResourceIds = useCallback( + async (ids: string[]) => { + const resourceUids = ids + .map((id) => resources.find((r) => r.findingId === id)?.resourceUid) + .filter(Boolean) as string[]; + if (resourceUids.length === 0) return []; + return resolveFindingIds({ + checkId: group.checkId, + resourceUids, + }); + }, + [resources, group.checkId], + ); + + const handleMuteClick = async () => { + setIsResolvingIds(true); + const findingIds = await resolveResourceIds(selectedFindingIds); + setResolvedFindingIds(findingIds); + setIsResolvingIds(false); + if (findingIds.length > 0) { + setIsMuteModalOpen(true); + } + }; + const selectableRowCount = resources.filter((r) => !r.isMuted).length; const getRowCanSelect = (row: Row): boolean => { @@ -130,6 +162,7 @@ export function FindingsGroupDrillDown({ const handleMuteComplete = () => { clearSelection(); + setResolvedFindingIds([]); router.refresh(); }; @@ -170,6 +203,7 @@ export function FindingsGroupDrillDown({ selectedFindings: [], clearSelection, isSelected, + resolveMuteIds: resolveResourceIds, }} >
{selectedFindingIds.length > 0 && ( - + <> + +
+ +
+ )} ([]); + const [isResolvingIds, setIsResolvingIds] = useState(false); + + const handleMuteClick = async () => { + setIsResolvingIds(true); + const findingIds = await resolveFindingIdsByCheckIds({ + checkIds: selectedFindingIds, + }); + setResolvedFindingIds(findingIds); + setIsResolvingIds(false); + if (findingIds.length > 0) { + setIsMuteModalOpen(true); + } + }; + + /** Shared resolver for row action dropdowns (via context). */ + const resolveMuteIds = useCallback( + async (checkIds: string[]) => resolveFindingIdsByCheckIds({ checkIds }), + [], + ); + const handleMuteComplete = () => { clearSelection(); + setResolvedFindingIds([]); router.refresh(); }; @@ -124,6 +152,7 @@ export function FindingsGroupTable({ selectedFindings, clearSelection, isSelected, + resolveMuteIds, }} > {selectedFindingIds.length > 0 && ( - + <> + +
+ +
+ )} ); diff --git a/ui/components/findings/table/findings-selection-context.tsx b/ui/components/findings/table/findings-selection-context.tsx index 7539bea821..e88250dbd5 100644 --- a/ui/components/findings/table/findings-selection-context.tsx +++ b/ui/components/findings/table/findings-selection-context.tsx @@ -4,10 +4,11 @@ import { createContext, useContext } from "react"; interface FindingsSelectionContextValue { selectedFindingIds: string[]; - selectedFindings: any[]; clearSelection: () => void; isSelected: (id: string) => boolean; + /** Resolves display IDs (check_ids or resource_ids) into real finding UUIDs for the mute API. */ + resolveMuteIds?: (ids: string[]) => Promise; } export const FindingsSelectionContext =