From c2b80924618a555e3151dfb1699feb874e1ec74c Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:55:21 +0200 Subject: [PATCH] feat(ui): hint the resource re-check beside Last seen (#12883) --- .../findings/recheck-resource-action-item.tsx | 17 +--- .../recheck-resource-icon-button.test.tsx | 78 +++++++++++++++++++ .../findings/recheck-resource-icon-button.tsx | 62 +++++++++++++++ .../table/column-finding-resources.test.tsx | 39 ++++++++++ .../table/column-finding-resources.tsx | 26 +++++-- .../resource-detail-drawer-content.test.tsx | 33 ++++++++ .../resource-detail-drawer-content.tsx | 27 ++++--- ui/hooks/use-partial-scan-target.ts | 23 ++++++ 8 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 ui/components/findings/recheck-resource-icon-button.test.tsx create mode 100644 ui/components/findings/recheck-resource-icon-button.tsx create mode 100644 ui/hooks/use-partial-scan-target.ts diff --git a/ui/components/findings/recheck-resource-action-item.tsx b/ui/components/findings/recheck-resource-action-item.tsx index cc667a791d..9489a255fb 100644 --- a/ui/components/findings/recheck-resource-action-item.tsx +++ b/ui/components/findings/recheck-resource-action-item.tsx @@ -3,12 +3,7 @@ import { RefreshCw } from "lucide-react"; import { ActionDropdownItem } from "@/components/shadcn/dropdown"; -import { useAuth } from "@/hooks/use-auth"; -import { - isPartialScanAvailable, - isPartialScanTarget, -} from "@/lib/partial-scans"; -import { isCloud } from "@/lib/shared/env"; +import { usePartialScanTarget } from "@/hooks/use-partial-scan-target"; import { usePartialScanStore } from "@/store"; import type { PartialScanTarget } from "@/types/partial-scans"; @@ -22,21 +17,17 @@ interface RecheckResourceActionItemProps { export const RecheckResourceActionItem = ({ target, }: RecheckResourceActionItemProps) => { - const { hasPermission } = useAuth(); + const resolvedTarget = usePartialScanTarget(target); const openPartialScan = usePartialScanStore((state) => state.openPartialScan); - const isAvailable = isPartialScanAvailable({ - cloudEnabled: isCloud(), - canManageScans: hasPermission("manage_scans"), - }); - if (!isAvailable || !isPartialScanTarget(target)) return null; + if (!resolvedTarget) return null; return ( } label={RECHECK_RESOURCE_LABEL} aria-label={RECHECK_RESOURCE_LABEL} - onSelect={() => openPartialScan(target)} + onSelect={() => openPartialScan(resolvedTarget)} /> ); }; diff --git a/ui/components/findings/recheck-resource-icon-button.test.tsx b/ui/components/findings/recheck-resource-icon-button.test.tsx new file mode 100644 index 0000000000..ae19aa2161 --- /dev/null +++ b/ui/components/findings/recheck-resource-icon-button.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { isCloudMock, hasPermissionMock } = vi.hoisted(() => ({ + isCloudMock: vi.fn(() => true), + hasPermissionMock: vi.fn(() => true), +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ hasPermission: hasPermissionMock }), +})); + +import { usePartialScanStore } from "@/store/partial-scan/store"; + +import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item"; +import { RecheckResourceIconButton } from "./recheck-resource-icon-button"; + +const target = { + providerUid: "123456789012", + providerType: "aws", + providerAlias: "prod", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", +}; + +describe("RecheckResourceIconButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + isCloudMock.mockReturnValue(true); + hasPermissionMock.mockReturnValue(true); + usePartialScanStore.getState().closePartialScan(); + }); + + it("opens the confirmation for the resource without triggering the row", async () => { + // The button sits inside a clickable row that opens the detail drawer. + const user = userEvent.setup(); + const onRowClick = vi.fn(); + render( +
+ +
, + ); + + await user.click( + screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL }), + ); + + expect(usePartialScanStore.getState().activeTarget).toEqual(target); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it("is always green and pulsing", () => { + render(); + + const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL }); + expect(button.className).toContain("text-button-primary"); + expect(button.className).toContain("animate-pulse"); + }); + + it("is hidden outside Prowler Cloud", () => { + isCloudMock.mockReturnValue(false); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("is hidden without the manage_scans permission", () => { + hasPermissionMock.mockReturnValue(false); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/recheck-resource-icon-button.tsx b/ui/components/findings/recheck-resource-icon-button.tsx new file mode 100644 index 0000000000..dc57bcce81 --- /dev/null +++ b/ui/components/findings/recheck-resource-icon-button.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import type { MouseEvent } from "react"; + +import { Button } from "@/components/shadcn/button/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { usePartialScanTarget } from "@/hooks/use-partial-scan-target"; +import { cn } from "@/lib/utils"; +import { usePartialScanStore } from "@/store"; +import type { PartialScanTarget } from "@/types/partial-scans"; + +import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item"; + +interface RecheckResourceIconButtonProps { + target: Partial | null | undefined; + className?: string; +} + +// Beside "last seen": always green and pulsing, so the re-check is noticed. +// It is an action, not a notification, so it never settles into a seen state. +export function RecheckResourceIconButton({ + target, + className, +}: RecheckResourceIconButtonProps) { + const resolvedTarget = usePartialScanTarget(target); + const openPartialScan = usePartialScanStore((state) => state.openPartialScan); + + if (!resolvedTarget) return null; + + const handleClick = (event: MouseEvent) => { + // The row itself opens the detail drawer on click. + event.stopPropagation(); + openPartialScan(resolvedTarget); + }; + + return ( + + + + + {RECHECK_RESOURCE_LABEL} + + ); +} diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index 3492ce411e..8c31599cbe 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -321,6 +321,24 @@ function renderResourceActionsCell({ render(
{CellComponent({ row: { original: resource, index: 0 } })}
); } +function renderLastSeenCell(resource: FindingResourceRow = makeResource()) { + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 1, + }); + const column = columns.find( + (col) => (col as { id?: string }).id === "lastSeen", + ); + if (!column?.cell) { + throw new Error("lastSeen column not found"); + } + const CellComponent = column.cell as (props: { + row: { original: FindingResourceRow; index: number }; + }) => ReactNode; + + render(
{CellComponent({ row: { original: resource, index: 0 } })}
); +} + describe("column-finding-resources", () => { beforeEach(() => { vi.clearAllMocks(); @@ -349,6 +367,27 @@ describe("column-finding-resources", () => { }); }); + it("offers the re-check beside Last seen on Cloud rows", async () => { + // The quiet icon next to the timestamp opens the same confirmation as ⋮. + const user = userEvent.setup(); + isCloudMock.mockReturnValue(true); + usePartialScanStore.getState().closePartialScan(); + renderLastSeenCell(); + + await user.click(screen.getByRole("button", { name: "Re-check resource" })); + + expect(usePartialScanStore.getState().activeTarget).toEqual( + expect.objectContaining({ resourceUid: "arn:aws:s3:::my-bucket" }), + ); + }); + + it("keeps Last seen plain outside Prowler Cloud", () => { + renderLastSeenCell(); + + expect(screen.getByText("2024-01-01T00:00:00Z")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + it("hides the re-check outside Prowler Cloud", () => { renderResourceActionsCell(); diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index ed5afdf7d5..31a5e25e74 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -7,6 +7,7 @@ import { useContext, useState } from "react"; import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; +import { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button"; import { Checkbox } from "@/components/shadcn"; import { ActionDropdown, @@ -72,6 +73,16 @@ const buildResourceFindingItem = (resource: FindingResourceRow) => region: resource.region, }); +// Shared by the ⋮ menu item and the "Last seen" icon so both open the same +// confirmation for the same resource. +const buildResourceRecheckTarget = (resource: FindingResourceRow) => ({ + providerUid: resource.providerUid, + providerType: resource.providerType, + providerAlias: resource.providerAlias, + resourceUid: resource.resourceUid, + resourceName: resource.resourceName, +}); + const ResourceRowActions = ({ row, findingTitle, @@ -201,13 +212,7 @@ const ResourceRowActions = ({ payload={jiraPayload} /> {isCloud() && ( ( - + + + + ), enableSorting: false, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index d763cff391..a8a55195fb 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -2294,4 +2294,37 @@ describe("ResourceDetailDrawerContent — re-check resource", () => { resourceName: "my-bucket", }); }); + + it("should offer the re-check beside Last detected with the provider id", async () => { + const user = userEvent.setup(); + mockIsCloud.mockReturnValue(true); + render( + , + ); + + const metadataRow = screen.getByTestId( + "resource-detail-secondary-metadata-row", + ); + await user.click( + within(metadataRow).getByRole("button", { name: "Re-check resource" }), + ); + + expect(usePartialScanStore.getState().activeTarget).toEqual( + expect.objectContaining({ + providerId: "provider-1", + resourceUid: "arn:aws:s3:::bucket", + }), + ); + }); }); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index bbe765af85..a02617e3c1 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -28,6 +28,7 @@ import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-acti import { MarkdownContainer } from "@/components/findings/markdown-container"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; +import { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button"; import { getComplianceIcon } from "@/components/icons"; import { Badge, @@ -409,6 +410,14 @@ export function ResourceDetailDrawerContent({ const resourceRegionLabel = resourceRegion || "-"; const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null; const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null; + const recheckTarget = { + providerId: f?.providerId, + providerUid, + providerType, + providerAlias, + resourceUid, + resourceName, + }; const hasPrev = currentIndex > 0; const hasNext = currentIndex < totalResources - 1; const selectedScanIds = parseSelectedScanIds( @@ -727,7 +736,12 @@ export function ResourceDetailDrawerContent({ variant="compact" className="min-w-0" > - + + + {f && ( + + )} + - + {externalResourceTarget && ( } diff --git a/ui/hooks/use-partial-scan-target.ts b/ui/hooks/use-partial-scan-target.ts new file mode 100644 index 0000000000..ba36f4659b --- /dev/null +++ b/ui/hooks/use-partial-scan-target.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { + isPartialScanAvailable, + isPartialScanTarget, +} from "@/lib/partial-scans"; +import { isCloud } from "@/lib/shared/env"; +import type { PartialScanTarget } from "@/types/partial-scans"; + +/** The target to re-check, or null when the feature or the row cannot offer it. */ +export function usePartialScanTarget( + target: Partial | null | undefined, +): PartialScanTarget | null { + const { hasPermission } = useAuth(); + + const isAvailable = isPartialScanAvailable({ + cloudEnabled: isCloud(), + canManageScans: hasPermission("manage_scans"), + }); + + return isAvailable && isPartialScanTarget(target) ? target : null; +}