diff --git a/ui/components/findings/table/column-finding-groups.tsx b/ui/components/findings/table/column-finding-groups.tsx index f46e5b2afd..8e86f571b0 100644 --- a/ui/components/findings/table/column-finding-groups.tsx +++ b/ui/components/findings/table/column-finding-groups.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib"; import { - getFindingGroupDelta, + getFilteredFindingGroupDelta, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow, ProviderType } from "@/types"; @@ -29,6 +29,8 @@ interface GetColumnFindingGroupsOptions { expandedCheckId?: string | null; /** True when the expanded group has individually selected resources */ hasResourceSelection?: boolean; + /** Active URL filters — used to make the delta indicator status-aware */ + filters?: Record; } const VISIBLE_DISABLED_CHECKBOX_CLASS = @@ -40,6 +42,7 @@ export function getColumnFindingGroups({ onDrillDown, expandedCheckId, hasResourceSelection = false, + filters = {}, }: GetColumnFindingGroupsOptions): ColumnDef[] { const selectedCount = Object.values(rowSelection).filter(Boolean).length; const isAllSelected = @@ -80,7 +83,7 @@ export function getColumnFindingGroups({ const group = row.original; const allMuted = isFindingGroupMuted(group); const isExpanded = expandedCheckId === group.checkId; - const deltaKey = getFindingGroupDelta(group); + const deltaKey = getFilteredFindingGroupDelta(group, filters); const delta = deltaKey === "new" ? DeltaValues.NEW diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 6677ef5c71..611ada375c 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -24,7 +24,7 @@ import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; import { useInfiniteResources } from "@/hooks/use-infinite-resources"; import { cn, hasDateOrScanFilter } from "@/lib"; import { - getFindingGroupDelta, + getFilteredFindingGroupDelta, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow, FindingResourceRow } from "@/types"; @@ -156,7 +156,7 @@ export function FindingsGroupDrillDown({ }); // Delta for the sticky header - const deltaKey = getFindingGroupDelta(group); + const deltaKey = getFilteredFindingGroupDelta(group, filters); const delta = deltaKey === "new" ? DeltaValues.NEW diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 876d828d3f..2946a22349 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -173,6 +173,7 @@ export function FindingsGroupTable({ onDrillDown: handleDrillDown, expandedCheckId, hasResourceSelection, + filters, }); const renderAfterRow = (row: Row) => { diff --git a/ui/lib/findings-groups.test.ts b/ui/lib/findings-groups.test.ts index 4d6dcdfc1d..9fa73c5b1c 100644 --- a/ui/lib/findings-groups.test.ts +++ b/ui/lib/findings-groups.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import type { FindingGroupRow } from "@/types"; -import { getFindingGroupDelta, isFindingGroupMuted } from "./findings-groups"; +import { + getActiveStatusFilter, + getFilteredFindingGroupDelta, + getFindingGroupDelta, + isFindingGroupMuted, +} from "./findings-groups"; function makeGroup(overrides?: Partial): FindingGroupRow { return { @@ -100,3 +105,134 @@ describe("getFindingGroupDelta", () => { ).toBe("new"); }); }); + +describe("getActiveStatusFilter", () => { + it("returns null when no status filter is active", () => { + expect(getActiveStatusFilter({})).toBeNull(); + }); + + it("returns the single value from filter[status]", () => { + const result = getActiveStatusFilter({ "filter[status]": "FAIL" }); + expect(result).toEqual(new Set(["FAIL"])); + }); + + it("returns the parsed set from filter[status__in]", () => { + const result = getActiveStatusFilter({ + "filter[status__in]": "FAIL,MANUAL", + }); + expect(result).toEqual(new Set(["FAIL", "MANUAL"])); + }); + + it("prefers filter[status] over filter[status__in] when both are present", () => { + const result = getActiveStatusFilter({ + "filter[status]": "PASS", + "filter[status__in]": "FAIL,MANUAL", + }); + expect(result).toEqual(new Set(["PASS"])); + }); + + it("ignores unknown status values and returns null if nothing remains", () => { + expect( + getActiveStatusFilter({ "filter[status__in]": "UNKNOWN,FOO" }), + ).toBeNull(); + }); +}); + +describe("getFilteredFindingGroupDelta", () => { + it("falls back to the aggregate delta when no status filter is active", () => { + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newPassCount: 2, + }), + {}, + ), + ).toBe("new"); + }); + + it("ignores deltas that belong to filtered-out statuses", () => { + // Filter is FAIL, but the only delta is a new PASS → should be hidden. + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newPassCount: 3, + }), + { "filter[status__in]": "FAIL" }, + ), + ).toBe("none"); + }); + + it("surfaces FAIL deltas when the filter is FAIL", () => { + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newFailCount: 1, + }), + { "filter[status]": "FAIL" }, + ), + ).toBe("new"); + }); + + it("counts muted breakdown counters towards the filtered status", () => { + // A muted new FAIL still belongs to the FAIL bucket — a FAIL filter + // should still light up the "new" indicator. + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newFailMutedCount: 1, + }), + { "filter[status]": "FAIL" }, + ), + ).toBe("new"); + }); + + it("sums multiple filtered statuses from filter[status__in]", () => { + // Filter is FAIL+MANUAL, new delta is only in MANUAL → should still show. + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newManualCount: 1, + }), + { "filter[status__in]": "FAIL,MANUAL" }, + ), + ).toBe("new"); + }); + + it("prefers new over changed within the filtered status", () => { + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newFailCount: 1, + changedFailCount: 2, + }), + { "filter[status]": "FAIL" }, + ), + ).toBe("new"); + }); + + it("returns changed when only changed counters match the filtered status", () => { + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newPassCount: 2, // filtered out + changedFailCount: 1, + }), + { "filter[status]": "FAIL" }, + ), + ).toBe("changed"); + }); + + it("falls back to the aggregate delta when breakdowns are missing (legacy API)", () => { + // No breakdown fields populated but legacy newCount is set. With a FAIL + // filter active we cannot know which status bucket it belongs to, so we + // fall back to showing the delta rather than silently hiding it. + expect( + getFilteredFindingGroupDelta( + makeGroup({ + newCount: 1, + }), + { "filter[status]": "FAIL" }, + ), + ).toBe("new"); + }); +}); diff --git a/ui/lib/findings-groups.ts b/ui/lib/findings-groups.ts index 2f48dfcae8..7f15e9ad2c 100644 --- a/ui/lib/findings-groups.ts +++ b/ui/lib/findings-groups.ts @@ -75,3 +75,127 @@ export function getFindingGroupDelta( return "none"; } + +const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const; +type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number]; + +type FindingGroupFiltersRecord = Record; + +function parseStatusFilterValue( + rawValue: string | string[] | undefined, +): FindingGroupStatus[] { + if (!rawValue) { + return []; + } + + const joined = Array.isArray(rawValue) ? rawValue.join(",") : rawValue; + + return joined + .split(",") + .map((status) => status.trim().toUpperCase()) + .filter((status): status is FindingGroupStatus => + (FINDING_GROUP_STATUSES as readonly string[]).includes(status), + ); +} + +/** + * Returns the set of statuses the user has explicitly narrowed the findings + * view to, or null when no status filter is active (→ all statuses should be + * considered). Supports both `filter[status]` (single value) and + * `filter[status__in]` (comma-separated values). + */ +export function getActiveStatusFilter( + filters: FindingGroupFiltersRecord, +): Set | null { + const direct = parseStatusFilterValue(filters["filter[status]"]); + if (direct.length > 0) { + return new Set(direct); + } + + const multi = parseStatusFilterValue(filters["filter[status__in]"]); + if (multi.length > 0) { + return new Set(multi); + } + + return null; +} + +function hasAnyDeltaBreakdown(group: FindingGroupDeltaState): boolean { + return ( + (group.newFailCount ?? 0) > 0 || + (group.newFailMutedCount ?? 0) > 0 || + (group.newPassCount ?? 0) > 0 || + (group.newPassMutedCount ?? 0) > 0 || + (group.newManualCount ?? 0) > 0 || + (group.newManualMutedCount ?? 0) > 0 || + (group.changedFailCount ?? 0) > 0 || + (group.changedFailMutedCount ?? 0) > 0 || + (group.changedPassCount ?? 0) > 0 || + (group.changedPassMutedCount ?? 0) > 0 || + (group.changedManualCount ?? 0) > 0 || + (group.changedManualMutedCount ?? 0) > 0 + ); +} + +function getNewDeltaForStatuses( + group: FindingGroupDeltaState, + statuses: Set, +): number { + let total = 0; + if (statuses.has("FAIL")) { + total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0); + } + if (statuses.has("PASS")) { + total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0); + } + if (statuses.has("MANUAL")) { + total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0); + } + return total; +} + +function getChangedDeltaForStatuses( + group: FindingGroupDeltaState, + statuses: Set, +): number { + let total = 0; + if (statuses.has("FAIL")) { + total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0); + } + if (statuses.has("PASS")) { + total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0); + } + if (statuses.has("MANUAL")) { + total += + (group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0); + } + return total; +} + +/** + * Filter-aware variant of {@link getFindingGroupDelta}. When a status filter + * is active, only delta counters belonging to the filtered statuses contribute + * to the indicator. When no status filter is active, or when the API response + * lacks breakdown counters (legacy shape), this falls back to the aggregate + * delta so rows still surface deltas correctly. + */ +export function getFilteredFindingGroupDelta( + group: FindingGroupDeltaState, + filters: FindingGroupFiltersRecord, +): "new" | "changed" | "none" { + const activeStatuses = getActiveStatusFilter(filters); + + if (!activeStatuses || !hasAnyDeltaBreakdown(group)) { + return getFindingGroupDelta(group); + } + + if (getNewDeltaForStatuses(group, activeStatuses) > 0) { + return "new"; + } + + if (getChangedDeltaForStatuses(group, activeStatuses) > 0) { + return "changed"; + } + + return "none"; +}