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;
+}