Compare commits

...

4 Commits

Author SHA1 Message Date
Hugo P.Brito 98932c19e1 Merge remote-tracking branch 'origin/master' into tmp-1454-merge 2026-05-18 14:50:44 +01:00
Hugo Pereira Brito ea8299c6b5 Merge branch 'master' into PROWLER-1454-findings-groups-update-table-immediately-after-muting-a-group 2026-05-14 11:07:39 +01:00
Hugo P.Brito 658e475636 docs(ui): add changelog entry for PROWLER-1454 fix 2026-05-14 10:52:13 +01:00
Hugo P.Brito 8ddb784259 fix(ui): hide muted finding groups immediately with TTL persistence
- Track optimistically-muted check_ids in sessionStorage with 90s TTL
- Wire row-action and floating-mute paths through selection context
- Hide expanded group when its last unmuted resource is muted
- Honor Include muted findings filter to stay in sync with reload
2026-05-14 10:48:04 +01:00
9 changed files with 557 additions and 38 deletions
+1
View File
@@ -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>
+89
View File
@@ -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();
});
});
+127
View File
@@ -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({});
}