fix(ui): restore scheduled scan column (#11411)

This commit is contained in:
Alejandro Bailo
2026-06-01 14:34:58 +02:00
committed by GitHub
parent 9d2a8d9108
commit a769e37615
10 changed files with 247 additions and 0 deletions
+9
View File
@@ -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");
});
});
+3
View File
@@ -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();
});
});
+49
View File
@@ -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
View File
@@ -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");
+4
View File
@@ -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.
* {
+2
View File
@@ -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",