From d93c7dcc4d7a789ac32178f90ccd4f304b6eca35 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:06:45 +0100 Subject: [PATCH] feat(ui): implement simple Mutelist and add new view (#9577) Co-authored-by: Alan Buscaglia --- ui/AGENTS.md | 26 + ui/CHANGELOG.md | 1 + ui/actions/mute-rules/index.ts | 9 + ui/actions/mute-rules/mute-rules.ts | 383 +++++++++++++++ ui/actions/mute-rules/types/index.ts | 1 + .../mute-rules/types/mute-rules.types.ts | 82 ++++ ui/app/(prowler)/findings/page.tsx | 7 +- .../_components/advanced-mutelist-form.tsx | 297 ++++++++++++ .../mutelist/_components/simple/index.ts | 6 + .../simple/mute-rule-edit-form.tsx | 93 ++++ .../simple/mute-rule-enabled-toggle.tsx | 54 +++ .../simple/mute-rule-row-actions.tsx | 84 ++++ .../_components/simple/mute-rules-columns.tsx | 110 +++++ .../simple/mute-rules-table-client.tsx | 145 ++++++ .../_components/simple/mute-rules-table.tsx | 109 +++++ ui/app/(prowler)/mutelist/mutelist-tabs.tsx | 35 ++ ui/app/(prowler)/mutelist/page.tsx | 28 ++ .../client-accordion-content.tsx | 10 +- .../custom-checkbox-muted-findings.tsx | 71 ++- .../findings/floating-mute-button.tsx | 44 ++ .../findings/mute-findings-modal.tsx | 130 +++++ .../findings/table/column-findings.tsx | 449 +++++++++++------- .../findings/table/data-table-row-actions.tsx | 92 +++- .../table/findings-selection-context.tsx | 30 ++ .../table/findings-table-with-selection.tsx | 111 +++++ ui/components/findings/table/index.ts | 2 + .../saml/saml-integration-card.tsx | 48 +- ui/components/providers/forms/index.ts | 1 - .../forms/muted-findings-config-form.tsx | 274 ----------- .../muted-findings-config-button.tsx | 69 +-- ui/components/shadcn/checkbox/checkbox.tsx | 31 ++ ui/components/shadcn/index.ts | 1 + ui/components/shadcn/tooltip.tsx | 5 +- ui/components/ui/sidebar/menu.tsx | 6 - ui/components/ui/table/data-table.tsx | 23 +- ui/lib/menu-list.ts | 35 +- ui/middleware.ts | 14 + ui/package.json | 1 + ui/pnpm-lock.yaml | 32 ++ ui/store/ui/store.ts | 16 - 40 files changed, 2349 insertions(+), 616 deletions(-) create mode 100644 ui/actions/mute-rules/index.ts create mode 100644 ui/actions/mute-rules/mute-rules.ts create mode 100644 ui/actions/mute-rules/types/index.ts create mode 100644 ui/actions/mute-rules/types/mute-rules.types.ts create mode 100644 ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/index.ts create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rule-edit-form.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rule-row-actions.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rules-columns.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rules-table-client.tsx create mode 100644 ui/app/(prowler)/mutelist/_components/simple/mute-rules-table.tsx create mode 100644 ui/app/(prowler)/mutelist/mutelist-tabs.tsx create mode 100644 ui/app/(prowler)/mutelist/page.tsx create mode 100644 ui/components/findings/floating-mute-button.tsx create mode 100644 ui/components/findings/mute-findings-modal.tsx create mode 100644 ui/components/findings/table/findings-selection-context.tsx create mode 100644 ui/components/findings/table/findings-table-with-selection.tsx delete mode 100644 ui/components/providers/forms/muted-findings-config-form.tsx create mode 100644 ui/components/shadcn/checkbox/checkbox.tsx diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 63aa456ea9..5eada68033 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -13,6 +13,32 @@ - ALWAYS: `const X = { A: "a", B: "b" } as const; type T = typeof X[keyof typeof X]` - NEVER: `type T = "a" | "b"` +### Interfaces + +- ALWAYS: One level depth only; object property → dedicated interface (recursive) +- ALWAYS: Reuse via `extends` +- NEVER: Inline nested objects + +```typescript +// ✅ CORRECT +interface UserAddress { + street: string; + city: string; +} +interface User { + id: string; + address: UserAddress; +} +interface Admin extends User { + permissions: string[]; +} + +// ❌ WRONG +interface User { + address: { street: string; city: string }; +} +``` + ### Styling - Single class: `className="bg-slate-800 text-white"` diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 998265beff..dd1ec66e15 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Risk Radar component with category-based severity breakdown to Overview page [(#9532)](https://github.com/prowler-cloud/prowler/pull/9532) - More extensive resource details (partition, details and metadata) within Findings detail and Resources detail view [(#9515)](https://github.com/prowler-cloud/prowler/pull/9515) - Integrated Prowler MCP server with Lighthouse AI for dynamic tool execution [(#9255)](https://github.com/prowler-cloud/prowler/pull/9255) +- Implement "MuteList Simple" feature allowing users to mute findings directly from the findings table with checkbox selection, and a new dedicated /mutelist route with Simple (mute rules list) and Advanced (YAML config) tabs. [(#9577)](https://github.com/prowler-cloud/prowler/pull/9577) ### 🔄 Changed diff --git a/ui/actions/mute-rules/index.ts b/ui/actions/mute-rules/index.ts new file mode 100644 index 0000000000..5e1203b611 --- /dev/null +++ b/ui/actions/mute-rules/index.ts @@ -0,0 +1,9 @@ +export { + createMuteRule, + deleteMuteRule, + getMuteRule, + getMuteRules, + toggleMuteRule, + updateMuteRule, +} from "./mute-rules"; +export * from "./types"; diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts new file mode 100644 index 0000000000..d8c5b7bbd4 --- /dev/null +++ b/ui/actions/mute-rules/mute-rules.ts @@ -0,0 +1,383 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; + +import { + DeleteMuteRuleActionState, + MuteRuleActionState, + MuteRuleData, + MuteRulesResponse, +} from "./types"; + +interface GetMuteRulesParams { + page?: number; + pageSize?: number; + sort?: string; + filters?: Record; +} + +export const getMuteRules = async ( + params: GetMuteRulesParams = {}, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/mute-rules`); + + if (params.page) { + url.searchParams.append("page[number]", params.page.toString()); + } + if (params.pageSize) { + url.searchParams.append("page[size]", params.pageSize.toString()); + } + if (params.sort) { + url.searchParams.append("sort", params.sort); + } + if (params.filters) { + Object.entries(params.filters).forEach(([key, value]) => { + url.searchParams.append(`filter[${key}]`, value); + }); + } + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + next: { revalidate: 0 }, + }); + + if (!response.ok) { + // Don't log authorization errors as they're expected when endpoint is not available + if (response.status !== 401 && response.status !== 403) { + console.error(`Failed to fetch mute rules: ${response.statusText}`); + } + return undefined; + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Error fetching mute rules:", error); + return undefined; + } +}; + +export const getMuteRule = async ( + id: string, +): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/mute-rules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + + if (!response.ok) { + // Don't log authorization errors as they're expected when endpoint is not available + if (response.status !== 401 && response.status !== 403) { + console.error(`Failed to fetch mute rule: ${response.statusText}`); + } + return undefined; + } + + const data = await response.json(); + return data.data; + } catch (error) { + console.error("Error fetching mute rule:", error); + return undefined; + } +}; + +export const createMuteRule = async ( + _prevState: MuteRuleActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + + const name = formData.get("name") as string; + const reason = formData.get("reason") as string; + const findingIdsRaw = formData.get("finding_ids") as string; + + // Validate required fields + if (!name || name.length < 3) { + return { + errors: { + name: "Name must be at least 3 characters", + }, + }; + } + + if (!reason || reason.length < 3) { + return { + errors: { + reason: "Reason must be at least 3 characters", + }, + }; + } + + let findingIds: string[]; + try { + findingIds = JSON.parse(findingIdsRaw); + if (!Array.isArray(findingIds) || findingIds.length === 0) { + throw new Error("Invalid finding IDs"); + } + } catch { + return { + errors: { + finding_ids: "At least one finding must be selected", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/mute-rules`); + + const bodyData = { + data: { + type: "mute-rules", + attributes: { + name, + reason, + finding_ids: findingIds, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + let errorMessage = `Failed to create mute rule: ${response.statusText}`; + try { + const errorData = await response.json(); + errorMessage = + errorData?.errors?.[0]?.detail || errorData?.message || errorMessage; + } catch { + // JSON parsing failed, use default error message + } + throw new Error(errorMessage); + } + + revalidatePath("/findings"); + revalidatePath("/mutelist"); + + return { + success: "Mute rule created successfully! Findings are now muted.", + }; + } catch (error) { + console.error("Error creating mute rule:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating mute rule. Please try again.", + }, + }; + } +}; + +export const updateMuteRule = async ( + _prevState: MuteRuleActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + + const id = formData.get("id") as string; + const name = formData.get("name") as string; + const reason = formData.get("reason") as string; + const enabledRaw = formData.get("enabled") as string; + + if (!id) { + return { + errors: { + general: "Mute rule ID is required for update", + }, + }; + } + + // Validate optional fields if provided + const validateOptionalField = ( + value: string | null, + fieldName: string, + minLength = 3, + ): MuteRuleActionState | null => { + if (value && value.length > 0 && value.length < minLength) { + return { + errors: { + [fieldName]: `${fieldName.charAt(0).toUpperCase() + fieldName.slice(1)} must be at least ${minLength} characters`, + }, + }; + } + return null; + }; + + const nameError = validateOptionalField(name, "name"); + if (nameError) return nameError; + + const reasonError = validateOptionalField(reason, "reason"); + if (reasonError) return reasonError; + + try { + const url = new URL(`${apiBaseUrl}/mute-rules/${id}`); + + const attributes: Record = {}; + if (name) attributes.name = name; + if (reason) attributes.reason = reason; + if (enabledRaw !== null && enabledRaw !== undefined) { + attributes.enabled = enabledRaw === "true"; + } + + const bodyData = { + data: { + type: "mute-rules", + id, + attributes, + }, + }; + + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + let errorMessage = `Failed to update mute rule: ${response.statusText}`; + try { + const errorData = await response.json(); + errorMessage = + errorData?.errors?.[0]?.detail || errorData?.message || errorMessage; + } catch { + // JSON parsing failed, use default error message + } + throw new Error(errorMessage); + } + + revalidatePath("/mutelist"); + + return { success: "Mute rule updated successfully!" }; + } catch (error) { + console.error("Error updating mute rule:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating mute rule. Please try again.", + }, + }; + } +}; + +export const toggleMuteRule = async ( + id: string, + enabled: boolean, +): Promise<{ success?: string; error?: string }> => { + const headers = await getAuthHeaders({ contentType: true }); + + try { + const url = new URL(`${apiBaseUrl}/mute-rules/${id}`); + + const bodyData = { + data: { + type: "mute-rules", + id, + attributes: { + enabled, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + let errorMessage = `Failed to toggle mute rule: ${response.statusText}`; + try { + const errorData = await response.json(); + errorMessage = + errorData?.errors?.[0]?.detail || errorData?.message || errorMessage; + } catch { + // JSON parsing failed, use default error message + } + throw new Error(errorMessage); + } + + revalidatePath("/mutelist"); + + return { + success: `Mute rule ${enabled ? "enabled" : "disabled"} successfully!`, + }; + } catch (error) { + console.error("Error toggling mute rule:", error); + return { + error: + error instanceof Error + ? error.message + : "Error toggling mute rule. Please try again.", + }; + } +}; + +export const deleteMuteRule = async ( + _prevState: DeleteMuteRuleActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const id = formData.get("id") as string; + + if (!id) { + return { + errors: { + general: "Mute rule ID is required for deletion", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/mute-rules/${id}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to delete mute rule: ${response.statusText}`, + ); + } + + revalidatePath("/mutelist"); + + return { success: "Mute rule deleted successfully!" }; + } catch (error) { + console.error("Error deleting mute rule:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error deleting mute rule. Please try again.", + }, + }; + } +}; + +// Note: Adding findings to existing mute rules is not supported by the API. +// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields. +// finding_ids can only be specified when creating a new mute rule. + +// Note: Unmute functionality is not currently supported by the API. +// The FindingViewSet only allows GET operations, and deleting a mute rule +// does not unmute the findings ("Previously muted findings remain muted"). diff --git a/ui/actions/mute-rules/types/index.ts b/ui/actions/mute-rules/types/index.ts new file mode 100644 index 0000000000..6a43c2fcdb --- /dev/null +++ b/ui/actions/mute-rules/types/index.ts @@ -0,0 +1 @@ +export * from "./mute-rules.types"; diff --git a/ui/actions/mute-rules/types/mute-rules.types.ts b/ui/actions/mute-rules/types/mute-rules.types.ts new file mode 100644 index 0000000000..7c130453de --- /dev/null +++ b/ui/actions/mute-rules/types/mute-rules.types.ts @@ -0,0 +1,82 @@ +// Mute Rules Types +// Corresponds to the /mute-rules endpoint + +// Base relationship data structure +export interface RelationshipData { + type: "users"; + id: string; +} + +export interface CreatedByRelationship { + data: RelationshipData | null; +} + +export interface MuteRuleRelationships { + created_by?: CreatedByRelationship; +} + +export interface MuteRuleAttributes { + inserted_at: string; + updated_at: string; + name: string; + reason: string; + enabled: boolean; + finding_uids: string[]; +} + +export interface MuteRuleData { + type: "mute-rules"; + id: string; + attributes: MuteRuleAttributes; + relationships?: MuteRuleRelationships; +} + +// Response pagination and links +export interface MuteRulesPagination { + page: number; + pages: number; + count: number; +} + +export interface MuteRulesMeta { + pagination: MuteRulesPagination; +} + +export interface MuteRulesLinks { + first: string; + last: string; + next: string | null; + prev: string | null; +} + +export interface MuteRulesResponse { + data: MuteRuleData[]; + meta: MuteRulesMeta; + links: MuteRulesLinks; +} + +export interface MuteRuleResponse { + data: MuteRuleData; +} + +// Action state types +export interface MuteRuleActionErrors { + name?: string; + reason?: string; + finding_ids?: string; + general?: string; +} + +export type MuteRuleActionState = { + errors?: MuteRuleActionErrors; + success?: string; +} | null; + +export interface DeleteMuteRuleActionErrors { + general?: string; +} + +export type DeleteMuteRuleActionState = { + errors?: DeleteMuteRuleActionErrors; + success?: string; +} | null; diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 1b4911c5c1..7fbb2e47c1 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -11,11 +11,10 @@ import { getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { FindingsFilters } from "@/components/findings/findings-filters"; import { - ColumnFindings, + FindingsTableWithSelection, SkeletonTableFindings, } from "@/components/findings/table"; import { ContentLayout } from "@/components/ui"; -import { DataTable } from "@/components/ui/table"; import { createDict, createScanDetailsMapping, @@ -166,9 +165,7 @@ const SSRDataTable = async ({

{findingsData.errors[0].detail}

)} - diff --git a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx new file mode 100644 index 0000000000..6309d1f0af --- /dev/null +++ b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { Textarea } from "@heroui/input"; +import { Trash2 } from "lucide-react"; +import { useActionState, useEffect, useState } from "react"; + +import { + createMutedFindingsConfig, + deleteMutedFindingsConfig, + getMutedFindingsConfig, + updateMutedFindingsConfig, +} from "@/actions/processors"; +import { Button, Card, Skeleton } from "@/components/shadcn"; +import { useToast } from "@/components/ui"; +import { CustomAlertModal } from "@/components/ui/custom"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { fontMono } from "@/config/fonts"; +import { + convertToYaml, + defaultMutedFindingsConfig, + parseYamlValidation, +} from "@/lib/yaml"; +import { + MutedFindingsConfigActionState, + ProcessorData, +} from "@/types/processors"; + +export function AdvancedMutelistForm() { + const [config, setConfig] = useState(null); + const [configText, setConfigText] = useState(""); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [yamlValidation, setYamlValidation] = useState<{ + isValid: boolean; + error?: string; + }>({ isValid: true }); + const [hasUserStartedTyping, setHasUserStartedTyping] = useState(false); + + // Unified action that decides to create or update based on ID presence + const saveConfig = async ( + _prevState: MutedFindingsConfigActionState, + formData: FormData, + ): Promise => { + const id = formData.get("id"); + if (id) { + return updateMutedFindingsConfig(_prevState, formData); + } + return createMutedFindingsConfig(_prevState, formData); + }; + + const [state, formAction, isPending] = useActionState< + MutedFindingsConfigActionState, + FormData + >(saveConfig, null); + + const { toast } = useToast(); + + useEffect(() => { + getMutedFindingsConfig().then((result) => { + setConfig(result || null); + const yamlConfig = convertToYaml(result?.attributes.configuration || ""); + setConfigText(yamlConfig); + setHasUserStartedTyping(false); + if (yamlConfig) { + setYamlValidation(parseYamlValidation(yamlConfig)); + } + setIsLoading(false); + }); + }, []); + + useEffect(() => { + if (state?.success) { + toast({ + title: "Configuration saved successfully", + description: state.success, + }); + // Reload config to get the created/updated data (shows Delete button) + getMutedFindingsConfig().then((result) => { + setConfig(result || null); + }); + } else if (state?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.errors.general, + }); + } else if (state?.errors?.configuration) { + setHasUserStartedTyping(false); + } + }, [state, toast]); + + const handleConfigChange = (value: string) => { + setConfigText(value); + setHasUserStartedTyping(true); + const validation = parseYamlValidation(value); + setYamlValidation(validation); + }; + + const handleDelete = async () => { + if (!config) return; + + setIsDeleting(true); + const formData = new FormData(); + formData.append("id", config.id); + + try { + const result = await deleteMutedFindingsConfig(null, formData); + if (result?.success) { + toast({ + title: "Configuration deleted successfully", + description: result.success, + }); + setConfig(null); + setConfigText(""); + } else if (result?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: result.errors.general, + }); + } + } catch { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: "Error deleting configuration. Please try again.", + }); + } finally { + setIsDeleting(false); + setShowDeleteConfirmation(false); + } + }; + + if (isLoading) { + return ( + +
+ + + + +
+ + +
+
+
+ ); + } + + return ( + <> + {/* Delete Confirmation Modal */} + +
+

+ Are you sure you want to delete this configuration? This action + cannot be undone. +

+
+ + +
+
+
+ + +
+ {config && } + +
+
+

+ Advanced Mutelist Configuration +

+
    +
  • + + This Mutelist configuration will take effect on the next + scan. + +
  • +
  • + Use this for pattern-based muting with wildcards, regions, and + tags. +
  • +
  • + Learn more about configuring the Mutelist{" "} + + here + + . +
  • +
  • + A default Mutelist is used to exclude certain predefined + resources if no Mutelist is provided. +
  • +
+
+ +
+ +
+