From a769e3761532d9332cb64078ef09ebf7ffb15292 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:34:58 +0200 Subject: [PATCH] fix(ui): restore scheduled scan column (#11411) --- ui/app/(prowler)/providers/page.test.ts | 9 ++ ui/app/(prowler)/providers/page.tsx | 3 + .../scans/cli-import-banner.test.tsx | 89 +++++++++++++++++++ ui/components/scans/cli-import-banner.tsx | 49 ++++++++++ ui/components/scans/index.ts | 1 + ui/components/scans/scans-page-shell.test.tsx | 57 ++++++++++++ ui/components/scans/scans-page-shell.tsx | 4 + .../scans/table/scan-jobs-columns.test.tsx | 20 +++++ .../scans/table/scan-jobs-columns.tsx | 13 +++ ui/lib/external-urls.ts | 2 + 10 files changed, 247 insertions(+) create mode 100644 ui/components/scans/cli-import-banner.test.tsx create mode 100644 ui/components/scans/cli-import-banner.tsx diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 52e22c9ba8..96612380df 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -34,4 +34,13 @@ describe("providers page", () => { expect(source).toContain("size: 160"); expect(source).toContain("size: 140"); }); + + it("keeps the CLI import banner gated by the Cloud environment", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV"); + expect(source).toContain("{isCloudEnvironment && + {isCloudEnvironment && } { + let store: Record = {}; + + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), + }; +})(); + +Object.defineProperty(window, "localStorage", { + value: localStorageMock, + writable: true, +}); + +describe("CliImportBanner", () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it("renders the banner when not dismissed", () => { + render(); + + expect( + screen.getByText(/Import findings from Prowler CLI/), + ).toBeInTheDocument(); + }); + + it("renders a link to the documentation", () => { + render(); + + const link = screen.getByRole("link", { name: "Learn more" }); + + expect(link).toHaveAttribute("href", DOCS_URLS.FINDINGS_INGESTION); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("does not render when previously dismissed", () => { + localStorageMock.setItem(STORAGE_KEY, "true"); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("dismisses the banner and persists to localStorage on close", async () => { + const user = userEvent.setup(); + + render(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + + await user.click(closeButton); + + expect( + screen.queryByText(/Import findings from Prowler CLI/), + ).not.toBeInTheDocument(); + expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true"); + }); + + it("renders with role='alert'", () => { + render(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/cli-import-banner.tsx b/ui/components/scans/cli-import-banner.tsx new file mode 100644 index 0000000000..f566c4ae05 --- /dev/null +++ b/ui/components/scans/cli-import-banner.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Upload } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { Alert, AlertTitle } from "@/components/shadcn"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { DOCS_URLS } from "@/lib/external-urls"; +import { cn } from "@/lib/utils"; + +const STORAGE_KEY = "prowler:cli-import-banner-dismissed"; + +export const CliImportBanner = ({ className }: { className?: string }) => { + const [isVisible, setIsVisible] = useState(null); + + useMountEffect(() => { + const isDismissed = localStorage.getItem(STORAGE_KEY) === "true"; + setIsVisible(!isDismissed); + }); + + const handleClose = () => { + localStorage.setItem(STORAGE_KEY, "true"); + setIsVisible(false); + }; + + if (isVisible === null || !isVisible) return null; + + return ( + + + + Import findings from Prowler CLI —{" "} + + Learn more + + + + ); +}; diff --git a/ui/components/scans/index.ts b/ui/components/scans/index.ts index 9b28f6c2c8..a35e33e8d5 100644 --- a/ui/components/scans/index.ts +++ b/ui/components/scans/index.ts @@ -1 +1,2 @@ export * from "./auto-refresh"; +export * from "./cli-import-banner"; diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx index 8f62ba0192..8c3bbddd7d 100644 --- a/ui/components/scans/scans-page-shell.test.tsx +++ b/ui/components/scans/scans-page-shell.test.tsx @@ -16,6 +16,32 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({ scansFilterBarSpy: vi.fn(), })); +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), + }; +})(); + +Object.defineProperty(window, "localStorage", { + value: localStorageMock, + writable: true, +}); + vi.mock("next/navigation", () => ({ usePathname: () => "/scans", useRouter: () => ({ @@ -120,6 +146,7 @@ describe("ScansPageShell", () => { afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + localStorageMock.clear(); searchParamsValue.current = ""; useScansStore.getState().closeLaunchScanModal(); }); @@ -191,6 +218,36 @@ describe("ScansPageShell", () => { expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible(); }); + it("shows the CLI import banner in Cloud", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + render( + +
Scans table
+
, + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + /import findings from prowler cli/i, + ); + expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute( + "href", + "https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings", + ); + }); + + it("hides the CLI import banner outside Cloud", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
Scans table
+
, + ); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("keeps launch scan with filters and mutelist with tabs", () => { vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 79579f59df..7e068f3f58 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -19,6 +19,7 @@ import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; import type { ProviderProps } from "@/types/providers"; +import { CliImportBanner } from "./cli-import-banner"; import { LaunchScanModal } from "./launch-scan-modal"; import { ScansFilterBar } from "./scans-filter-bar"; import { useScansFilters } from "./use-scans-filters"; @@ -53,6 +54,7 @@ export function ScansPageShell({ const hasConnectedProviders = providers.some( (provider) => provider.attributes.connection.connected === true, ); + const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; const launchOpen = isLaunchScanModalOpen || urlLaunchOpen; @@ -104,6 +106,8 @@ export function ScansPageShell({ + {isCloudEnvironment && } + ({ }, }); +const makeScheduledScan = (): ScanProps => ({ + ...makeCompletedScan(), + attributes: { + ...makeCompletedScan().attributes, + trigger: "scheduled", + state: "scheduled", + scheduled_at: "2026-01-01T10:00:00Z", + next_scan_at: "2026-01-02T10:00:00Z", + }, +}); + const renderCell = ( columnId: string, scan: ScanProps, @@ -136,6 +147,7 @@ describe("getScanJobsColumns", () => { expect(getColumnIds(SCAN_JOBS_TAB.SCHEDULED)).toEqual([ "account", "scanInfo", + "scanSchedule", "actions", ]); }); @@ -168,4 +180,12 @@ describe("getScanJobsColumns", () => { expect(screen.getByText("Type")).toBeInTheDocument(); expect(screen.queryByText("Schedule")).not.toBeInTheDocument(); }); + + it("keeps the scheduled column without repeating the scheduled label in each row", () => { + renderHeader(SCAN_JOBS_TAB.SCHEDULED, "scanSchedule"); + renderCell("scanSchedule", makeScheduledScan(), SCAN_JOBS_TAB.SCHEDULED); + + expect(screen.getByText("Schedule")).toBeInTheDocument(); + expect(screen.queryByText("Scheduled")).not.toBeInTheDocument(); + }); }); diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx index da9106a331..824f1e6f91 100644 --- a/ui/components/scans/table/scan-jobs-columns.tsx +++ b/ui/components/scans/table/scan-jobs-columns.tsx @@ -48,6 +48,18 @@ const getScanScheduleColumn = (title: string): ColumnDef => ({ cell: ({ row }) => , }); +const scheduledScanScheduleColumn: ColumnDef = { + id: "scanSchedule", + accessorFn: (row) => row.attributes.scheduled_at, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, +}; + const resourcesColumn: ColumnDef = { id: "resources", header: ({ column }) => ( @@ -139,6 +151,7 @@ const completedColumns = (): ColumnDef[] => [ const scheduledColumns = (): ColumnDef[] => [ accountColumn, scanInfoColumn, + scheduledScanScheduleColumn, /* * TODO: Restore this column when the API exposes the last completed scan date for this schedule. * { diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 8c6105e4ec..f56461e7a5 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -4,6 +4,8 @@ import { IntegrationType } from "../types/integrations"; export const DOCS_URLS = { FINDINGS_ANALYSIS: "https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings", + FINDINGS_INGESTION: + "https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings", AWS_ORGANIZATIONS: "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",