mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): hint the resource re-check beside Last seen (#12883)
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
+33
@@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-11
@@ -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" />}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user