mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): restore scheduled scan column (#11411)
This commit is contained in:
@@ -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 && <CliImportBanner");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
||||
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { CliImportBanner } from "@/components/scans";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
@@ -19,6 +20,7 @@ export default async function Providers({
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const activeTab = getProviderTab(resolvedSearchParams.tab);
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
|
||||
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
|
||||
@@ -26,6 +28,7 @@ export default async function Providers({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Providers" icon="lucide:cloud-cog">
|
||||
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
|
||||
<FilterTransitionWrapper>
|
||||
<ProviderPageTabs
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
|
||||
import { CliImportBanner } from "./cli-import-banner";
|
||||
|
||||
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
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(<CliImportBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Import findings from Prowler CLI/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a link to the documentation", () => {
|
||||
render(<CliImportBanner />);
|
||||
|
||||
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(<CliImportBanner />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("dismisses the banner and persists to localStorage on close", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CliImportBanner />);
|
||||
|
||||
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(<CliImportBanner />);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean | null>(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 (
|
||||
<Alert
|
||||
variant="info"
|
||||
onClose={handleClose}
|
||||
className={cn("animate-fade-in", className)}
|
||||
>
|
||||
<Upload />
|
||||
<AlertTitle>
|
||||
Import findings from Prowler CLI —{" "}
|
||||
<Link
|
||||
href={DOCS_URLS.FINDINGS_INGESTION}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-normal underline underline-offset-2"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./auto-refresh";
|
||||
export * from "./cli-import-banner";
|
||||
|
||||
@@ -16,6 +16,32 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
|
||||
scansFilterBarSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
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(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps launch scan with filters and mutelist with tabs", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCloudEnvironment && <CliImportBanner />}
|
||||
|
||||
<Tabs
|
||||
value={filters.activeTab}
|
||||
onValueChange={filters.setTab}
|
||||
|
||||
@@ -81,6 +81,17 @@ const makeCompletedScan = (): ScanProps => ({
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,18 @@ const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
|
||||
cell: ({ row }) => <ScheduleCell scan={row.original} />,
|
||||
});
|
||||
|
||||
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.scheduled_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
|
||||
),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
id: "resources",
|
||||
header: ({ column }) => (
|
||||
@@ -139,6 +151,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scheduledScanScheduleColumn,
|
||||
/*
|
||||
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
|
||||
* {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user