mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
fix(ui): resolve finding IDs before muting in grouped findings view
This commit is contained in:
@@ -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<string[]> => {
|
||||
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<string[]> => {
|
||||
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,
|
||||
|
||||
@@ -48,8 +48,10 @@ function getFailingForLabel(firstSeenAt: string | null): string | null {
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const resource = row.original;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
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<FindingResourceRow> }) => {
|
||||
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<FindingResourceRow> }) => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={getMuteIds()}
|
||||
onComplete={clearSelection}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown
|
||||
@@ -101,13 +123,15 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
icon={
|
||||
resource.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={getMuteLabel()}
|
||||
disabled={resource.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={resource.isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
|
||||
@@ -68,41 +68,57 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
|
||||
// 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<string[]>([]);
|
||||
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<T extends FindingRowData>({
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={getMuteIds()}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
|
||||
@@ -145,15 +161,15 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
icon={
|
||||
isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : isResolving ? (
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={getMuteLabel()}
|
||||
disabled={isMuted}
|
||||
onSelect={() => {
|
||||
setIsMuteModalOpen(true);
|
||||
}}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={isMuted || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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<FindingResourceRow>): 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,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -282,11 +316,29 @@ export function FindingsGroupDrillDown({
|
||||
</div>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={handleMuteClick}
|
||||
disabled={isResolvingIds}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isResolvingIds ? (
|
||||
<TreeSpinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
Mute ({selectedFindingIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ResourceDetailDrawer
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { VolumeX } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIdsByCheckIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
import { MuteFindingsModal } from "../mute-findings-modal";
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
import { FindingsGroupDrillDown } from "./findings-group-drill-down";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
@@ -79,8 +83,32 @@ export function FindingsGroupTable({
|
||||
return selectedFindingIds.includes(id);
|
||||
};
|
||||
|
||||
// Mute modal state — check IDs resolved to finding UUIDs on-click
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
@@ -140,11 +169,29 @@ export function FindingsGroupTable({
|
||||
/>
|
||||
|
||||
{selectedFindingIds.length > 0 && (
|
||||
<FloatingMuteButton
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={resolvedFindingIds}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
<Button
|
||||
onClick={handleMuteClick}
|
||||
disabled={isResolvingIds}
|
||||
size="lg"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isResolvingIds ? (
|
||||
<TreeSpinner className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)}
|
||||
Mute ({selectedFindingIds.length})
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<string[]>;
|
||||
}
|
||||
|
||||
export const FindingsSelectionContext =
|
||||
|
||||
Reference in New Issue
Block a user