feat(ui): hint the resource re-check beside Last seen (#12883)

This commit is contained in:
Alejandro Bailo
2026-09-25 14:55:21 +02:00
committed by GitHub
parent 26d9d24e5d
commit c2b8092461
8 changed files with 273 additions and 32 deletions
@@ -3,12 +3,7 @@
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { ActionDropdownItem } from "@/components/shadcn/dropdown"; import { ActionDropdownItem } from "@/components/shadcn/dropdown";
import { useAuth } from "@/hooks/use-auth"; import { usePartialScanTarget } from "@/hooks/use-partial-scan-target";
import {
isPartialScanAvailable,
isPartialScanTarget,
} from "@/lib/partial-scans";
import { isCloud } from "@/lib/shared/env";
import { usePartialScanStore } from "@/store"; import { usePartialScanStore } from "@/store";
import type { PartialScanTarget } from "@/types/partial-scans"; import type { PartialScanTarget } from "@/types/partial-scans";
@@ -22,21 +17,17 @@ interface RecheckResourceActionItemProps {
export const RecheckResourceActionItem = ({ export const RecheckResourceActionItem = ({
target, target,
}: RecheckResourceActionItemProps) => { }: RecheckResourceActionItemProps) => {
const { hasPermission } = useAuth(); const resolvedTarget = usePartialScanTarget(target);
const openPartialScan = usePartialScanStore((state) => state.openPartialScan); const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
const isAvailable = isPartialScanAvailable({ if (!resolvedTarget) return null;
cloudEnabled: isCloud(),
canManageScans: hasPermission("manage_scans"),
});
if (!isAvailable || !isPartialScanTarget(target)) return null;
return ( return (
<ActionDropdownItem <ActionDropdownItem
icon={<RefreshCw className="size-5" />} icon={<RefreshCw className="size-5" />}
label={RECHECK_RESOURCE_LABEL} label={RECHECK_RESOURCE_LABEL}
aria-label={RECHECK_RESOURCE_LABEL} aria-label={RECHECK_RESOURCE_LABEL}
onSelect={() => openPartialScan(target)} onSelect={() => openPartialScan(resolvedTarget)}
/> />
); );
}; };
@@ -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(
<div onClick={onRowClick}>
<RecheckResourceIconButton target={target} />
</div>,
);
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(<RecheckResourceIconButton target={target} />);
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(<RecheckResourceIconButton target={target} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("is hidden without the manage_scans permission", () => {
hasPermissionMock.mockReturnValue(false);
render(<RecheckResourceIconButton target={target} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -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<PartialScanTarget> | 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<HTMLButtonElement>) => {
// The row itself opens the detail drawer on click.
event.stopPropagation();
openPartialScan(resolvedTarget);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="bare"
size="icon"
aria-label={RECHECK_RESOURCE_LABEL}
onClick={handleClick}
className={cn(
"text-button-primary hover:text-button-primary active:text-button-primary size-5 shrink-0 rounded-md motion-safe:animate-pulse",
"hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused]",
className,
)}
>
<RefreshCw className="size-3.5" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>{RECHECK_RESOURCE_LABEL}</TooltipContent>
</Tooltip>
);
}
@@ -321,6 +321,24 @@ function renderResourceActionsCell({
render(<div>{CellComponent({ row: { original: resource, index: 0 } })}</div>); render(<div>{CellComponent({ row: { original: resource, index: 0 } })}</div>);
} }
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(<div>{CellComponent({ row: { original: resource, index: 0 } })}</div>);
}
describe("column-finding-resources", () => { describe("column-finding-resources", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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", () => { it("hides the re-check outside Prowler Cloud", () => {
renderResourceActionsCell(); renderResourceActionsCell();
@@ -7,6 +7,7 @@ import { useContext, useState } from "react";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item";
import { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button";
import { Checkbox } from "@/components/shadcn"; import { Checkbox } from "@/components/shadcn";
import { import {
ActionDropdown, ActionDropdown,
@@ -72,6 +73,16 @@ const buildResourceFindingItem = (resource: FindingResourceRow) =>
region: resource.region, 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 = ({ const ResourceRowActions = ({
row, row,
findingTitle, findingTitle,
@@ -201,13 +212,7 @@ const ResourceRowActions = ({
payload={jiraPayload} payload={jiraPayload}
/> />
<RecheckResourceActionItem <RecheckResourceActionItem
target={{ target={buildResourceRecheckTarget(resource)}
providerUid: resource.providerUid,
providerType: resource.providerType,
providerAlias: resource.providerAlias,
resourceUid: resource.resourceUid,
resourceName: resource.resourceName,
}}
/> />
{isCloud() && ( {isCloud() && (
<LighthouseSkillsSubmenu <LighthouseSkillsSubmenu
@@ -394,7 +399,12 @@ export function getColumnFindingResources({
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<InfoField label="Last seen" variant="compact"> <InfoField label="Last seen" variant="compact">
<DateWithTime dateTime={row.original.lastSeenAt} /> <span className="flex items-center gap-1.5">
<DateWithTime dateTime={row.original.lastSeenAt} />
<RecheckResourceIconButton
target={buildResourceRecheckTarget(row.original)}
/>
</span>
</InfoField> </InfoField>
), ),
enableSorting: false, enableSorting: false,
@@ -2294,4 +2294,37 @@ describe("ResourceDetailDrawerContent — re-check resource", () => {
resourceName: "my-bucket", 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(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
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",
}),
);
});
}); });
@@ -28,6 +28,7 @@ import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-acti
import { MarkdownContainer } from "@/components/findings/markdown-container"; import { MarkdownContainer } from "@/components/findings/markdown-container";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item";
import { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button";
import { getComplianceIcon } from "@/components/icons"; import { getComplianceIcon } from "@/components/icons";
import { import {
Badge, Badge,
@@ -409,6 +410,14 @@ export function ResourceDetailDrawerContent({
const resourceRegionLabel = resourceRegion || "-"; const resourceRegionLabel = resourceRegion || "-";
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null; const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null; const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
const recheckTarget = {
providerId: f?.providerId,
providerUid,
providerType,
providerAlias,
resourceUid,
resourceName,
};
const hasPrev = currentIndex > 0; const hasPrev = currentIndex > 0;
const hasNext = currentIndex < totalResources - 1; const hasNext = currentIndex < totalResources - 1;
const selectedScanIds = parseSelectedScanIds( const selectedScanIds = parseSelectedScanIds(
@@ -727,7 +736,12 @@ export function ResourceDetailDrawerContent({
variant="compact" variant="compact"
className="min-w-0" className="min-w-0"
> >
<DateWithTime inline dateTime={lastSeenAt || "-"} /> <span className="group flex items-center gap-1.5">
<DateWithTime inline dateTime={lastSeenAt || "-"} />
{f && (
<RecheckResourceIconButton target={recheckTarget} />
)}
</span>
</InfoField> </InfoField>
<InfoField <InfoField
label="First seen" label="First seen"
@@ -785,16 +799,7 @@ export function ResourceDetailDrawerContent({
label={buildJiraActionLabel({ findingCount: 1 })} label={buildJiraActionLabel({ findingCount: 1 })}
payload={jiraPayload} payload={jiraPayload}
/> />
<RecheckResourceActionItem <RecheckResourceActionItem target={recheckTarget} />
target={{
providerId: f.providerId,
providerUid,
providerType,
providerAlias,
resourceUid,
resourceName,
}}
/>
{externalResourceTarget && ( {externalResourceTarget && (
<ActionDropdownItem <ActionDropdownItem
icon={<ExternalLink className="size-5" />} icon={<ExternalLink className="size-5" />}
+23
View File
@@ -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<PartialScanTarget> | null | undefined,
): PartialScanTarget | null {
const { hasPermission } = useAuth();
const isAvailable = isPartialScanAvailable({
cloudEnabled: isCloud(),
canManageScans: hasPermission("manage_scans"),
});
return isAvailable && isPartialScanTarget(target) ? target : null;
}