mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-11 05:46:05 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98932c19e1 | |||
| ea8299c6b5 | |||
| 658e475636 | |||
| 8ddb784259 |
@@ -19,6 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Muting a finding group on `/findings` now hides the row immediately without waiting for a manual reload; muted `check_id`s are tracked client-side under `prowler:optimistic-muted-groups` in `sessionStorage` with a 90s TTL so a fast reload still honors the optimistic hide while the background reaggregation catches up [(#11170)](https://github.com/prowler-cloud/prowler/pull/11170)
|
||||
- Mute Findings modal now enforces the 100-character limit on the rule name input with a live counter and inline error, matching the existing reason field behaviour [(#11158)](https://github.com/prowler-cloud/prowler/pull/11158)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -87,9 +87,10 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
const mutedIds = getDisplayIds();
|
||||
clearSelection();
|
||||
setResolvedIds([]);
|
||||
onMuteComplete?.();
|
||||
onMuteComplete?.(mutedIds);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -89,11 +89,15 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
|
||||
// Get selection context - if there are other selected rows, include them
|
||||
const selectionContext = useContext(FindingsSelectionContext);
|
||||
const { selectedFindingIds, clearSelection, resolveMuteIds } =
|
||||
selectionContext || {
|
||||
selectedFindingIds: [],
|
||||
clearSelection: () => {},
|
||||
};
|
||||
const {
|
||||
selectedFindingIds,
|
||||
clearSelection,
|
||||
resolveMuteIds,
|
||||
onMuteComplete: contextOnMuteComplete,
|
||||
} = selectionContext || {
|
||||
selectedFindingIds: [],
|
||||
clearSelection: () => {},
|
||||
};
|
||||
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
@@ -175,8 +179,10 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
// the wrong findings would appear selected
|
||||
clearSelection();
|
||||
setResolvedIds([]);
|
||||
if (onMuteComplete) {
|
||||
onMuteComplete(getDisplayIds());
|
||||
const ids = getDisplayIds();
|
||||
const handler = onMuteComplete ?? contextOnMuteComplete;
|
||||
if (handler) {
|
||||
handler(ids);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type ReactNode, useContext } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import { FindingsGroupTable } from "./findings-group-table";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
const STORAGE_KEY = "prowler:optimistic-muted-groups";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
@@ -12,12 +18,37 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => (
|
||||
<div>
|
||||
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
|
||||
<span>10 Total Entries</span>
|
||||
</div>
|
||||
),
|
||||
DataTable: ({
|
||||
data,
|
||||
toolbarRightContent,
|
||||
}: {
|
||||
data?: FindingGroupRow[];
|
||||
toolbarRightContent?: ReactNode;
|
||||
}) => {
|
||||
const ctx = useContext(FindingsSelectionContext);
|
||||
const rows = data ?? [];
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
|
||||
<span data-testid="row-count">{rows.length}</span>
|
||||
<ul data-testid="visible-groups">
|
||||
{rows.map((row) => (
|
||||
<li key={row.checkId}>{row.checkId}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ctx.onMuteComplete?.([rows[0]?.checkId ?? ""])}
|
||||
>
|
||||
mute-first-row
|
||||
</button>
|
||||
<button type="button" onClick={() => ctx.onMuteComplete?.()}>
|
||||
mute-without-ids
|
||||
</button>
|
||||
<span>10 Total Entries</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({
|
||||
@@ -45,10 +76,35 @@ vi.mock("../floating-mute-button", () => ({
|
||||
FloatingMuteButton: () => null,
|
||||
}));
|
||||
|
||||
const buildGroup = (
|
||||
checkId: string,
|
||||
overrides: Partial<FindingGroupRow> = {},
|
||||
): FindingGroupRow =>
|
||||
({
|
||||
id: checkId,
|
||||
rowType: "group",
|
||||
checkId,
|
||||
checkTitle: `Title ${checkId}`,
|
||||
severity: "high",
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
resourcesTotal: 10,
|
||||
resourcesFail: 5,
|
||||
mutedCount: 0,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
providers: [],
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as FindingGroupRow;
|
||||
|
||||
describe("FindingsGroupTable", () => {
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("toolbar", () => {
|
||||
it("should render the muted findings checkbox inside the table toolbar", () => {
|
||||
// Given
|
||||
render(
|
||||
<FindingsGroupTable
|
||||
data={[]}
|
||||
@@ -65,14 +121,131 @@ describe("FindingsGroupTable", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const toolbar = screen.getByTestId("table-toolbar-right");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: "Include muted findings" }),
|
||||
).toBeInTheDocument();
|
||||
expect(toolbar).toHaveTextContent("Include muted findings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimistic mute via context onMuteComplete", () => {
|
||||
it("hides muted groups from the table immediately and persists them in sessionStorage", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsGroupTable
|
||||
data={[
|
||||
buildGroup("group-a"),
|
||||
buildGroup("group-b"),
|
||||
buildGroup("group-c"),
|
||||
]}
|
||||
resolvedFilters={{}}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("3");
|
||||
expect(screen.getByText("group-a")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "mute-first-row" }));
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("2");
|
||||
expect(screen.queryByText("group-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("group-b")).toBeInTheDocument();
|
||||
|
||||
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
expect(Object.keys(stored)).toContain("group-a");
|
||||
});
|
||||
|
||||
it("keeps muted groups visible when 'Include muted findings' is active (matches post-reload state)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsGroupTable
|
||||
data={[buildGroup("group-a"), buildGroup("group-b")]}
|
||||
resolvedFilters={{ "filter[muted]": "include" }}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("2");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "mute-first-row" }));
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("2");
|
||||
expect(screen.getByText("group-a")).toBeInTheDocument();
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when onMuteComplete fires without IDs (resource-level mute)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsGroupTable
|
||||
data={[buildGroup("group-a"), buildGroup("group-b")]}
|
||||
resolvedFilters={{}}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("2");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "mute-without-ids" }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("2");
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates from sessionStorage on mount", () => {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
"group-a": { expiresAt: Date.now() + 60_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<FindingsGroupTable
|
||||
data={[buildGroup("group-a"), buildGroup("group-b")]}
|
||||
resolvedFilters={{}}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("1");
|
||||
expect(screen.queryByText("group-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("group-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes the storage entry once the server payload no longer includes the group", async () => {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
"group-a": { expiresAt: Date.now() + 60_000 },
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<FindingsGroupTable
|
||||
data={[buildGroup("group-a"), buildGroup("group-b")]}
|
||||
resolvedFilters={{}}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("1");
|
||||
|
||||
rerender(
|
||||
<FindingsGroupTable
|
||||
data={[buildGroup("group-b")]}
|
||||
resolvedFilters={{}}
|
||||
hasHistoricalData={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("row-count")).toHaveTextContent("1");
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
|
||||
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { includesMutedFindings } from "@/lib/findings-filters";
|
||||
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
|
||||
import {
|
||||
loadOptimisticallyMutedCheckIds,
|
||||
persistOptimisticallyMutedCheckIds,
|
||||
removePersistedOptimisticEntries,
|
||||
} from "@/lib/optimistic-muted-groups";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
@@ -57,21 +63,53 @@ export function FindingsGroupTable({
|
||||
const [resourceSearchInput, setResourceSearchInput] = useState("");
|
||||
const [resourceSearch, setResourceSearch] = useState("");
|
||||
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
|
||||
// Group check_ids the user just muted, hidden client-side until the server
|
||||
// catches up. Hydrated from sessionStorage on mount so a fast reload still
|
||||
// honours the optimistic hide. See lib/optimistic-muted-groups.ts.
|
||||
const [optimisticallyMutedCheckIds, setOptimisticallyMutedCheckIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const inlineRef = useRef<InlineResourceContainerHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = loadOptimisticallyMutedCheckIds();
|
||||
if (persisted.size > 0) setOptimisticallyMutedCheckIds(persisted);
|
||||
}, []);
|
||||
|
||||
// State resets (selection, drill-down) are handled by the parent via
|
||||
// key={groupKey} — when data changes, the component remounts with fresh state.
|
||||
|
||||
const safeData = data ?? [];
|
||||
const visibleData = (data ?? []).filter(
|
||||
(g) => !optimisticallyMutedCheckIds.has(g.checkId),
|
||||
);
|
||||
const hasResourceSelection = resourceSelection.length > 0;
|
||||
const filters = resolvedFilters;
|
||||
|
||||
// When a previously-hidden group disappears from the server payload, drop
|
||||
// it from both client state and storage so we don't keep a stale entry.
|
||||
useEffect(() => {
|
||||
if (optimisticallyMutedCheckIds.size === 0) return;
|
||||
const incoming = new Set((data ?? []).map((g) => g.checkId));
|
||||
const confirmed: string[] = [];
|
||||
const stillPending = new Set<string>();
|
||||
optimisticallyMutedCheckIds.forEach((id) => {
|
||||
if (incoming.has(id)) {
|
||||
stillPending.add(id);
|
||||
} else {
|
||||
confirmed.push(id);
|
||||
}
|
||||
});
|
||||
if (confirmed.length > 0) {
|
||||
removePersistedOptimisticEntries(confirmed);
|
||||
setOptimisticallyMutedCheckIds(stillPending);
|
||||
}
|
||||
}, [data, optimisticallyMutedCheckIds]);
|
||||
|
||||
// Get selected group check IDs. When the expanded group has individual resource
|
||||
// selections, exclude it from group-level mute targets — the resource-level
|
||||
// FloatingMuteButton handles those.
|
||||
const selectedCheckIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => safeData[parseInt(idx)]?.checkId)
|
||||
.map((idx) => visibleData[parseInt(idx)]?.checkId)
|
||||
.filter(Boolean)
|
||||
.filter(
|
||||
(checkId) => !(hasResourceSelection && checkId === expandedCheckId),
|
||||
@@ -79,11 +117,11 @@ export function FindingsGroupTable({
|
||||
|
||||
const selectedFindings = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
.map((idx) => safeData[parseInt(idx)])
|
||||
.map((idx) => visibleData[parseInt(idx)])
|
||||
.filter(Boolean);
|
||||
|
||||
// Count of selectable rows (groups where not ALL findings are muted)
|
||||
const selectableRowCount = safeData.filter((g) =>
|
||||
const selectableRowCount = visibleData.filter((g) =>
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: g.resourcesFail,
|
||||
resourcesTotal: g.resourcesTotal,
|
||||
@@ -132,12 +170,12 @@ export function FindingsGroupTable({
|
||||
const resolveMuteIds = async (checkIds: string[]) =>
|
||||
resolveGroupMuteIds(checkIds);
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
clearSelection();
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
setResourceSelection([]);
|
||||
inlineRef.current?.clearSelection();
|
||||
inlineRef.current?.refresh();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
|
||||
@@ -156,12 +194,81 @@ export function FindingsGroupTable({
|
||||
setResourceSelection([]);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setExpandedCheckId(null);
|
||||
setExpandedGroup(null);
|
||||
setResourceSearchInput("");
|
||||
setResourceSearch("");
|
||||
const hideMutedGroups = (mutedCheckIds: string[]) => {
|
||||
if (mutedCheckIds.length === 0) return;
|
||||
// When the user opted into showing muted findings, the row stays visible
|
||||
// (with the muted indicator) after reload — don't hide it client-side or
|
||||
// we'll diverge from the post-reload state.
|
||||
if (includesMutedFindings(resolvedFilters)) return;
|
||||
persistOptimisticallyMutedCheckIds(mutedCheckIds);
|
||||
setOptimisticallyMutedCheckIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
mutedCheckIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
if (expandedCheckId && mutedCheckIds.includes(expandedCheckId)) {
|
||||
handleCollapse();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* True when muting `mutedResourceCount` findings is expected to leave the
|
||||
* given group fully muted on the next read. Conservative: requires that
|
||||
* every unmuted FAIL is covered AND there are no unmuted PASS/MANUAL
|
||||
* findings, which matches the API model where `muted=True` only when every
|
||||
* finding in the group is muted.
|
||||
*/
|
||||
const willResourceMuteEmptyGroup = (
|
||||
group: FindingGroupRow,
|
||||
mutedResourceCount: number,
|
||||
): boolean => {
|
||||
const unmutedFail = group.failCount ?? group.resourcesFail;
|
||||
const unmutedPass = group.passCount ?? 0;
|
||||
const unmutedManual = group.manualCount ?? 0;
|
||||
return (
|
||||
mutedResourceCount >= unmutedFail &&
|
||||
unmutedPass === 0 &&
|
||||
unmutedManual === 0
|
||||
);
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
// Snapshot the group selection BEFORE clearing it; the optimistic-hide
|
||||
// helper needs the IDs that were actually muted as whole groups.
|
||||
const mutedGroupCheckIds = [...selectedCheckIds];
|
||||
// If the FloatingMuteButton flow includes resource-level mutes that fully
|
||||
// empty the expanded group, hide that group too (the row would have stayed
|
||||
// visible otherwise until the server caught up).
|
||||
if (
|
||||
expandedGroup &&
|
||||
resourceSelection.length > 0 &&
|
||||
!mutedGroupCheckIds.includes(expandedGroup.checkId) &&
|
||||
willResourceMuteEmptyGroup(expandedGroup, resourceSelection.length)
|
||||
) {
|
||||
mutedGroupCheckIds.push(expandedGroup.checkId);
|
||||
}
|
||||
clearSelection();
|
||||
setResourceSelection([]);
|
||||
inlineRef.current?.clearSelection();
|
||||
inlineRef.current?.refresh();
|
||||
hideMutedGroups(mutedGroupCheckIds);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Triggered by group row-action mutes (single-row dropdown). Receives the
|
||||
// group check IDs that were sent to the mute API.
|
||||
const handleRowMuteComplete = (mutedIds?: string[]) => {
|
||||
hideMutedGroups(mutedIds ?? []);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Triggered by resource-row mutes inside the drill-down. Hides the
|
||||
// surrounding group when the mute leaves it fully muted.
|
||||
const handleResourceMuteFromDrillDown = (mutedResourceCount: number) => {
|
||||
if (mutedResourceCount <= 0 || !expandedGroup) return;
|
||||
if (willResourceMuteEmptyGroup(expandedGroup, mutedResourceCount)) {
|
||||
hideMutedGroups([expandedGroup.checkId]);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = getColumnFindingGroups({
|
||||
@@ -187,6 +294,7 @@ export function FindingsGroupTable({
|
||||
resourceSearch={resourceSearch}
|
||||
columnCount={columns.length}
|
||||
onResourceSelectionChange={setResourceSelection}
|
||||
onResourceMuteCompleted={handleResourceMuteFromDrillDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -199,11 +307,12 @@ export function FindingsGroupTable({
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds,
|
||||
onMuteComplete: handleRowMuteComplete,
|
||||
}}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
data={visibleData}
|
||||
metadata={metadata}
|
||||
enableRowSelection
|
||||
rowSelection={rowSelection}
|
||||
|
||||
@@ -11,8 +11,13 @@ interface FindingsSelectionContextValue {
|
||||
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[]>;
|
||||
/** Called after a mute operation completes to refresh data. */
|
||||
onMuteComplete?: () => void;
|
||||
/**
|
||||
* Called after a mute operation completes. Receives the display IDs that
|
||||
* were just muted (group check_ids for group rows, finding UUIDs for
|
||||
* resource rows). Parents can use them to hide rows optimistically while
|
||||
* the server-side reaggregation catches up.
|
||||
*/
|
||||
onMuteComplete?: (mutedIds?: string[]) => void;
|
||||
}
|
||||
|
||||
export const FindingsSelectionContext =
|
||||
|
||||
@@ -40,6 +40,10 @@ interface InlineResourceContainerProps {
|
||||
columnCount: number;
|
||||
/** Called with selected finding IDs (real UUIDs) for parent-level mute */
|
||||
onResourceSelectionChange: (findingIds: string[]) => void;
|
||||
/** Called after a resource-row mute completes inside the drill-down with
|
||||
* the count of finding UUIDs that were just muted. The parent uses it to
|
||||
* decide whether the surrounding finding group should also be hidden. */
|
||||
onResourceMuteCompleted?: (mutedCount: number) => void;
|
||||
ref?: React.Ref<InlineResourceContainerHandle>;
|
||||
}
|
||||
|
||||
@@ -126,6 +130,7 @@ export function InlineResourceContainer({
|
||||
resourceSearch,
|
||||
columnCount,
|
||||
onResourceSelectionChange,
|
||||
onResourceMuteCompleted,
|
||||
ref,
|
||||
}: InlineResourceContainerProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -210,7 +215,10 @@ export function InlineResourceContainer({
|
||||
clearSelection,
|
||||
isSelected,
|
||||
resolveMuteIds: resolveSelectedFindingIds,
|
||||
onMuteComplete: handleMuteComplete,
|
||||
onMuteComplete: (mutedIds?: string[]) => {
|
||||
handleMuteComplete();
|
||||
onResourceMuteCompleted?.(mutedIds?.length ?? 0);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clearAllOptimisticEntries,
|
||||
loadOptimisticallyMutedCheckIds,
|
||||
OPTIMISTIC_MUTED_GROUPS_TTL_MS,
|
||||
persistOptimisticallyMutedCheckIds,
|
||||
removePersistedOptimisticEntries,
|
||||
} from "./optimistic-muted-groups";
|
||||
|
||||
const STORAGE_KEY = "prowler:optimistic-muted-groups";
|
||||
|
||||
describe("optimistic-muted-groups", () => {
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns an empty set when storage is empty", () => {
|
||||
expect(loadOptimisticallyMutedCheckIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("persists ids and reloads them within the TTL window", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-a", "check-b"], 1_000);
|
||||
|
||||
expect(loadOptimisticallyMutedCheckIds(1_500)).toEqual(
|
||||
new Set(["check-a", "check-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("prunes expired entries on load and rewrites storage", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-old"], 0);
|
||||
persistOptimisticallyMutedCheckIds(
|
||||
["check-fresh"],
|
||||
OPTIMISTIC_MUTED_GROUPS_TTL_MS - 1_000,
|
||||
);
|
||||
|
||||
const result = loadOptimisticallyMutedCheckIds(
|
||||
OPTIMISTIC_MUTED_GROUPS_TTL_MS + 100,
|
||||
);
|
||||
|
||||
expect(result).toEqual(new Set(["check-fresh"]));
|
||||
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
expect(Object.keys(stored)).toEqual(["check-fresh"]);
|
||||
});
|
||||
|
||||
it("removes only the listed ids and keeps the rest", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-a", "check-b", "check-c"], 0);
|
||||
|
||||
removePersistedOptimisticEntries(["check-b"]);
|
||||
|
||||
expect(loadOptimisticallyMutedCheckIds(1_000)).toEqual(
|
||||
new Set(["check-a", "check-c"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes expiresAt when persisting an existing id", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-a"], 0);
|
||||
const firstStored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
|
||||
persistOptimisticallyMutedCheckIds(["check-a"], 5_000);
|
||||
const secondStored = JSON.parse(
|
||||
sessionStorage.getItem(STORAGE_KEY) ?? "{}",
|
||||
);
|
||||
|
||||
expect(secondStored["check-a"].expiresAt).toBeGreaterThan(
|
||||
firstStored["check-a"].expiresAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the storage entry when the map becomes empty", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-a"], 0);
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();
|
||||
|
||||
removePersistedOptimisticEntries(["check-a"]);
|
||||
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores corrupted storage payloads", () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "not-json");
|
||||
expect(loadOptimisticallyMutedCheckIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("clearAllOptimisticEntries removes the storage entry", () => {
|
||||
persistOptimisticallyMutedCheckIds(["check-a"], 0);
|
||||
clearAllOptimisticEntries();
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Per-tab persistence for finding groups the user just muted, used by the
|
||||
* findings table to keep a row hidden across a fast page reload while the
|
||||
* server-side reaggregation (Celery chain queued by POST /mute-rules) is
|
||||
* still in flight.
|
||||
*
|
||||
* Storage is `sessionStorage` (per-tab; closing the tab wipes it). Cross-tab
|
||||
* consistency is intentionally out of scope — the worst case is that another
|
||||
* tab keeps showing the row until the server data refreshes naturally.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "prowler:optimistic-muted-groups";
|
||||
|
||||
/** Default time-to-live for an optimistic entry. ~90s gives plenty of headroom
|
||||
* over the typical Celery reaggregation window. */
|
||||
export const OPTIMISTIC_MUTED_GROUPS_TTL_MS = 90_000;
|
||||
|
||||
interface StoredEntry {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
type StoredMap = Record<string, StoredEntry>;
|
||||
|
||||
function safeGetStorage(): Storage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMap(): StoredMap {
|
||||
const storage = safeGetStorage();
|
||||
if (!storage) return {};
|
||||
try {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as StoredMap;
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeMap(map: StoredMap): void {
|
||||
const storage = safeGetStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (Object.keys(map).length === 0) {
|
||||
storage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
}
|
||||
} catch {
|
||||
// Quota exceeded or storage disabled — silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpired(
|
||||
map: StoredMap,
|
||||
now: number,
|
||||
): {
|
||||
pruned: StoredMap;
|
||||
changed: boolean;
|
||||
} {
|
||||
let changed = false;
|
||||
const pruned: StoredMap = {};
|
||||
for (const [checkId, entry] of Object.entries(map)) {
|
||||
if (entry?.expiresAt && entry.expiresAt > now) {
|
||||
pruned[checkId] = entry;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return { pruned, changed };
|
||||
}
|
||||
|
||||
/** Return the set of currently-active optimistic checkIds. Side effect:
|
||||
* silently removes expired entries from storage. */
|
||||
export function loadOptimisticallyMutedCheckIds(
|
||||
now: number = Date.now(),
|
||||
): Set<string> {
|
||||
const map = readMap();
|
||||
const { pruned, changed } = pruneExpired(map, now);
|
||||
if (changed) writeMap(pruned);
|
||||
return new Set(Object.keys(pruned));
|
||||
}
|
||||
|
||||
/** Mark each checkId as optimistically muted, refreshing its TTL. */
|
||||
export function persistOptimisticallyMutedCheckIds(
|
||||
checkIds: Iterable<string>,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
const ids = Array.from(checkIds);
|
||||
if (ids.length === 0) return;
|
||||
const { pruned } = pruneExpired(readMap(), now);
|
||||
const expiresAt = now + OPTIMISTIC_MUTED_GROUPS_TTL_MS;
|
||||
for (const id of ids) {
|
||||
pruned[id] = { expiresAt };
|
||||
}
|
||||
writeMap(pruned);
|
||||
}
|
||||
|
||||
/** Drop the listed checkIds from storage (e.g. after the server payload no
|
||||
* longer mentions them). No-op for entries that aren't there. */
|
||||
export function removePersistedOptimisticEntries(
|
||||
checkIds: Iterable<string>,
|
||||
): void {
|
||||
const ids = Array.from(checkIds);
|
||||
if (ids.length === 0) return;
|
||||
const map = readMap();
|
||||
let changed = false;
|
||||
for (const id of ids) {
|
||||
if (id in map) {
|
||||
delete map[id];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) writeMap(map);
|
||||
}
|
||||
|
||||
/** Wipe every optimistic entry. Mainly useful for tests. */
|
||||
export function clearAllOptimisticEntries(): void {
|
||||
writeMap({});
|
||||
}
|
||||
Reference in New Issue
Block a user