mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +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 { 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 (
|
||||
<ActionDropdownItem
|
||||
icon={<RefreshCw className="size-5" />}
|
||||
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>);
|
||||
}
|
||||
|
||||
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", () => {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={{
|
||||
providerUid: resource.providerUid,
|
||||
providerType: resource.providerType,
|
||||
providerAlias: resource.providerAlias,
|
||||
resourceUid: resource.resourceUid,
|
||||
resourceName: resource.resourceName,
|
||||
}}
|
||||
target={buildResourceRecheckTarget(resource)}
|
||||
/>
|
||||
{isCloud() && (
|
||||
<LighthouseSkillsSubmenu
|
||||
@@ -394,7 +399,12 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
||||
+33
@@ -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(
|
||||
<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 { 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"
|
||||
>
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
<span className="group flex items-center gap-1.5">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
{f && (
|
||||
<RecheckResourceIconButton target={recheckTarget} />
|
||||
)}
|
||||
</span>
|
||||
</InfoField>
|
||||
<InfoField
|
||||
label="First seen"
|
||||
@@ -785,16 +799,7 @@ export function ResourceDetailDrawerContent({
|
||||
label={buildJiraActionLabel({ findingCount: 1 })}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={{
|
||||
providerId: f.providerId,
|
||||
providerUid,
|
||||
providerType,
|
||||
providerAlias,
|
||||
resourceUid,
|
||||
resourceName,
|
||||
}}
|
||||
/>
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
{externalResourceTarget && (
|
||||
<ActionDropdownItem
|
||||
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