diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index dc1b0c62a5..92d0fccb9f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- New Scan Jobs view with tab-specific Active, Completed, Scheduled, and Imported scan workflows [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258) - `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213) --- diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 62154f1042..8e6c5034d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AccountsSelector } from "./accounts-selector"; const multiSelectContentSpy = vi.fn(); +const multiSelectSpy = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), @@ -35,9 +37,25 @@ vi.mock("@/components/icons/providers-badge", () => ({ })); vi.mock("@/components/shadcn/select/multiselect", () => ({ - MultiSelect: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), + MultiSelect: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + multiSelectSpy({ open }); + return ( +
+ + {children} +
+ ); + }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
{children}
), @@ -58,14 +76,21 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ children, value, keywords, + onSelect, }: { children: React.ReactNode; value: string; keywords?: string[]; + onSelect?: (value: string) => void; }) => ( -
+
+ ), })); @@ -140,6 +165,16 @@ describe("AccountsSelector", () => { ).toHaveAttribute("data-keywords", expect.stringContaining("123456789012")); }); + it("can use provider UID values for pages whose API filters by provider_uid__in", () => { + render( + , + ); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-value", "123456789012"); + }); + it("disables select all when every account is already shown", () => { render(); @@ -148,4 +183,24 @@ describe("AccountsSelector", () => { ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("can close the dropdown after selecting a launch-scan provider", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /open selector/i })); + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true }); + + await user.click(screen.getByRole("button", { name: /production aws/i })); + + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 4e7ca5bf79..720a5e71db 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,7 +1,7 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; import { AlibabaCloudProviderBadge, @@ -36,6 +36,14 @@ import { type ProviderType, } from "@/types/providers"; +const ACCOUNT_SELECTOR_FILTER = { + PROVIDER_ID: "provider_id__in", + PROVIDER_UID: "provider_uid__in", +} as const; + +type AccountSelectorFilter = + (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; + const PROVIDER_ICON: Record = { aws: , azure: , @@ -59,12 +67,15 @@ const PROVIDER_ICON: Record = { interface AccountsSelectorBaseProps { providers: ProviderProps[]; search?: MultiSelectSearchProp; + filterKey?: AccountSelectorFilter; + id?: string; /** * Currently selected provider types (from the pending ProviderTypeSelector state). * Used only for contextual description/empty-state messaging — does NOT narrow * the list of available accounts, which remains independent of provider selection. */ selectedProviderTypes?: string[]; + closeOnSelect?: boolean; } /** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ @@ -99,41 +110,52 @@ export function AccountsSelector({ onBatchChange, selectedValues, selectedProviderTypes, + filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID, + id = "accounts-selector", search = { placeholder: "Search accounts...", emptyMessage: "No accounts found.", }, + closeOnSelect = false, }: AccountsSelectorProps) { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); + const [selectorOpen, setSelectorOpen] = useState(false); - const filterKey = "filter[provider_id__in]"; - const current = searchParams.get(filterKey) || ""; + const labelId = `${id}-label`; + const urlFilterKey = `filter[${filterKey}]`; + const current = searchParams.get(urlFilterKey) || ""; const urlSelectedIds = current ? current.split(",").filter(Boolean) : []; // In batch mode, use the parent-controlled pending values; otherwise, use URL state. const selectedIds = onBatchChange ? selectedValues : urlSelectedIds; const visibleProviders = providers; // .filter((p) => p.attributes.connection?.connected) + const getProviderValue = (provider: ProviderProps) => + filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID + ? provider.attributes.uid + : provider.id; const handleMultiValueChange = (ids: string[]) => { if (onBatchChange) { - onBatchChange("provider_id__in", ids); + onBatchChange(filterKey, ids); + if (closeOnSelect) setSelectorOpen(false); return; } navigateWithParams((params) => { - params.delete(filterKey); + params.delete(urlFilterKey); if (ids.length > 0) { - params.set(filterKey, ids.join(",")); + params.set(urlFilterKey, ids.join(",")); } }); + if (closeOnSelect) setSelectorOpen(false); }; const selectedLabel = () => { if (selectedIds.length === 0) return null; if (selectedIds.length === 1) { - const p = providers.find((pr) => pr.id === selectedIds[0]); + const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; return {name}; } @@ -152,19 +174,17 @@ export function AccountsSelector({ return (
-
{visibleProviders.map((p) => { - const id = p.id; + const value = getProviderValue(p); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; const icon = PROVIDER_ICON[providerType]; @@ -205,11 +225,14 @@ export function AccountsSelector({ ].filter(Boolean); return ( { + if (closeOnSelect) setSelectorOpen(false); + }} > {displayName} diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 1b045721ef..a9ba274e55 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,22 +3,17 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; -import { MutedFindingsConfigButton } from "@/components/providers"; -import { ScansFilters } from "@/components/scans"; -import { ScansLaunchSection } from "@/components/scans/scans-launch-section"; +import { ScansPageShell } from "@/components/scans/scans-page-shell"; +import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; +import { + getScanJobsTab, + getScanJobsTabFilters, + isScanStateFilterKey, +} from "@/components/scans/scans-table.utils"; import { SkeletonTableScans } from "@/components/scans/table"; -import { ScansTableWithPolling } from "@/components/scans/table/scans"; +import { ScanJobsTable } from "@/components/scans/table/scans/scan-jobs-table"; import { ContentLayout } from "@/components/ui"; -import { - createProviderDetailsMapping, - extractProviderUIDs, -} from "@/lib/provider-helpers"; -import { - ExpandedScanData, - ProviderProps, - ScanProps, - SearchParamsProps, -} from "@/types"; +import { ProviderProps, ScanProps, SearchParamsProps } from "@/types"; export default async function Scans({ searchParams, @@ -27,96 +22,36 @@ export default async function Scans({ }) { const session = await auth(); const resolvedSearchParams = await searchParams; - const filteredParams = { ...resolvedSearchParams }; - delete filteredParams.scanId; - - const [providersData, completedScansData] = await Promise.all([ - getAllProviders(), - getScans({ - filters: { "filter[state]": "completed" }, - pageSize: 50, - fields: { scans: "name,completed_at,provider" }, - include: "provider", - }), - ]); - - const completedScans: ExpandedScanData[] = (completedScansData?.data ?? []) - .map((scan: ScanProps) => { - const providerId = scan.relationships?.provider?.data?.id; - const providerData = completedScansData?.included?.find( - (item: { type: string; id: string }) => - item.type === "providers" && item.id === providerId, - ); - if (!providerData) return null; - return { - ...scan, - providerInfo: { - provider: providerData.attributes.provider, - uid: providerData.attributes.uid, - alias: providerData.attributes.alias, - }, - }; - }) - .filter(Boolean) as ExpandedScanData[]; - - const providerInfo = - providersData?.data - ?.filter( - (provider: ProviderProps) => - provider.attributes.connection.connected === true, - ) - .map((provider: ProviderProps) => ({ - providerId: provider.id, - alias: provider.attributes.alias, - providerType: provider.attributes.provider, - uid: provider.attributes.uid, - connected: provider.attributes.connection.connected, - })) || []; - - const thereIsNoProviders = - !providersData?.data || providersData.data.length === 0; - - const thereIsNoProvidersConnected = Boolean( - providersData?.data?.every( - (provider: ProviderProps) => !provider.attributes.connection.connected, - ), + + const providersData = await getAllProviders(); + const providers = providersData?.data ?? []; + + const connectedProviders = providers.filter( + (provider: ProviderProps) => + provider.attributes.connection.connected === true, ); + const thereIsNoProviders = providers.length === 0; + const thereIsNoProvidersConnected = + !thereIsNoProviders && connectedProviders.length === 0; const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); - // Extract provider UIDs and create provider details mapping for filtering - const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; - const providerDetails = providersData - ? createProviderDetailsMapping(providerUIDs, providersData) - : []; - return ( - - <> - + {thereIsNoProviders || thereIsNoProvidersConnected ? ( + + ) : ( + - {!thereIsNoProviders && ( -
- -
- -
- }> - - -
- )} - + > + }> + + +
+ )}
); } @@ -126,21 +61,26 @@ const SSRDataTableScans = async ({ }: { searchParams: SearchParamsProps; }) => { + const tab = getScanJobsTab(searchParams.tab); + const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const sort = searchParams.sort?.toString(); - // Extract all filter parameters, excluding scanId - const filters = Object.fromEntries( - Object.entries(searchParams).filter( - ([key]) => key.startsWith("filter[") && key !== "scanId", + const filters = { + ...Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => key.startsWith("filter[") && !isScanStateFilterKey(key), + ), ), - ); + ...getScanJobsTabFilters( + tab, + searchParams["filter[state__in]"] ?? searchParams["filter[state]"], + ), + }; - // Extract query from filters const query = (filters["filter[search]"] as string) || ""; - // Fetch scans data with provider information included const scansData = await getScans({ query, page, @@ -158,19 +98,12 @@ const SSRDataTableScans = async ({ scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; - if (!providerId) { - return { ...scan, providerInfo: null }; - } - - // Find the provider data in the included array const providerData = included?.find( (item: { type: string; id: string }) => item.type === "providers" && item.id === providerId, ); - if (!providerData) { - return { ...scan, providerInfo: null }; - } + if (!providerData) return scan; return { ...scan, @@ -182,11 +115,5 @@ const SSRDataTableScans = async ({ }; }) || []; - return ( - - ); + return ; }; diff --git a/ui/components/scans/edit-alias-modal.tsx b/ui/components/scans/edit-alias-modal.tsx new file mode 100644 index 0000000000..b26de16af0 --- /dev/null +++ b/ui/components/scans/edit-alias-modal.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Pencil } from "lucide-react"; +import { useRouter } from "next/navigation"; +import type { FormEvent } from "react"; +import { useEffect, useState } from "react"; + +import { updateScan } from "@/actions/scans"; +import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { FormButtons } from "@/components/ui/form"; +import { toast } from "@/components/ui/toast"; + +interface EditAliasModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + scanId: string; + currentAlias: string; +} + +const ALIAS_MIN_LENGTH = 3; +const ALIAS_MAX_LENGTH = 32; + +export function EditAliasModal({ + open, + onOpenChange, + scanId, + currentAlias, +}: EditAliasModalProps) { + const router = useRouter(); + const [alias, setAlias] = useState(currentAlias); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setAlias(currentAlias); + setError(null); + } + }, [open, currentAlias]); + + const closeModal = () => { + setError(null); + onOpenChange(false); + }; + + const validate = (value: string): string | null => { + if (value.length > 0 && value.length < ALIAS_MIN_LENGTH) { + return `Alias must be empty or have at least ${ALIAS_MIN_LENGTH} characters.`; + } + if (value.length > ALIAS_MAX_LENGTH) { + return `Alias must not exceed ${ALIAS_MAX_LENGTH} characters.`; + } + if (value === currentAlias) { + return "The new alias must be different from the current one."; + } + return null; + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + + const trimmed = alias.trim(); + const validationError = validate(trimmed); + if (validationError) { + setError(validationError); + return; + } + + setSubmitting(true); + setError(null); + + const formData = new FormData(); + formData.set("scanId", scanId); + formData.set("scanName", trimmed); + + const result = await updateScan(formData); + setSubmitting(false); + + if (result?.errors && result.errors.length > 0) { + setError(String(result.errors[0]?.detail ?? "Failed to update alias.")); + return; + } + + toast({ + title: "Alias updated", + description: "The scan alias was updated successfully.", + }); + closeModal(); + router.refresh(); + }; + + return ( + { + if (!nextOpen) closeModal(); + else onOpenChange(true); + }} + title="Edit Alias" + size="xl" + className="gap-8" + > +
+
+ + + Current alias:{" "} + + {currentAlias || "Unnamed"} + + +
+ + + Alias + setAlias(event.target.value)} + placeholder={currentAlias || "Enter scan alias"} + /> + + + {error && {error}} + + + +
+ ); +} diff --git a/ui/components/scans/forms/edit-scan-form.tsx b/ui/components/scans/forms/edit-scan-form.tsx deleted file mode 100644 index bb05b921a2..0000000000 --- a/ui/components/scans/forms/edit-scan-form.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Dispatch, SetStateAction } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; - -import { updateScan } from "@/actions/scans"; -import { useToast } from "@/components/ui"; -import { CustomInput } from "@/components/ui/custom"; -import { Form, FormButtons } from "@/components/ui/form"; -import { editScanFormSchema } from "@/types"; - -export const EditScanForm = ({ - scanId, - scanName, - setIsOpen, -}: { - scanId: string; - scanName: string; - setIsOpen: Dispatch>; -}) => { - const formSchema = editScanFormSchema(scanName); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - scanId: scanId, - scanName: scanName || "", - }, - }); - - const { toast } = useToast(); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: z.infer) => { - const formData = new FormData(); - - Object.entries(values).forEach( - ([key, value]) => value !== undefined && formData.append(key, value), - ); - - const data = await updateScan(formData); - - if (data?.errors && data.errors.length > 0) { - const error = data.errors[0]; - const errorMessage = `${error.detail}`; - // show error - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } else { - toast({ - title: "Success!", - description: "The scan was updated successfully.", - }); - setIsOpen(false); // Close the modal on success - } - }; - - return ( -
- -
- Current name:{" "} - {scanName || "Unnamed"} -
-
- -
- - - - - - ); -}; diff --git a/ui/components/scans/forms/index.ts b/ui/components/scans/forms/index.ts deleted file mode 100644 index 4acd302cb9..0000000000 --- a/ui/components/scans/forms/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./edit-scan-form"; -export * from "./schedule-form"; diff --git a/ui/components/scans/forms/schedule-form.tsx b/ui/components/scans/forms/schedule-form.tsx deleted file mode 100644 index 5a0e1e3a59..0000000000 --- a/ui/components/scans/forms/schedule-form.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Dispatch, SetStateAction } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; - -import { updateProvider } from "@/actions/providers"; -import { useToast } from "@/components/ui"; -import { CustomInput } from "@/components/ui/custom"; -import { Form, FormButtons } from "@/components/ui/form"; -import { scheduleScanFormSchema } from "@/types"; - -export const ScheduleForm = ({ - providerId, - scheduleDate, - setIsOpen, -}: { - providerId: string; - scheduleDate: string; - setIsOpen: Dispatch>; -}) => { - const formSchema = scheduleScanFormSchema(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId: providerId, - scheduleDate: scheduleDate, - }, - }); - - const { toast } = useToast(); - - const onSubmitClient = async (values: z.infer) => { - const formData = new FormData(); - - Object.entries(values).forEach( - ([key, value]) => value !== undefined && formData.append(key, value), - ); - const data = await updateProvider(formData); - - if (data?.errors && data.errors.length > 0) { - const error = data.errors[0]; - const errorMessage = `${error.detail}`; - // show error - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } else { - toast({ - title: "Success!", - description: "The scan was scheduled successfully.", - }); - setIsOpen(false); // Close the modal on success - } - }; - - return ( -
- - - - - - - - ); -}; diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx new file mode 100644 index 0000000000..052ad97478 --- /dev/null +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -0,0 +1,165 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { refreshMock, scanOnDemandMock } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + scanOnDemandMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("@/actions/scans", () => ({ + scanOnDemand: scanOnDemandMock, +})); + +vi.mock("@/components/ui/toast", () => ({ + toast: vi.fn(), +})); + +vi.mock("@/components/shadcn/modal", () => ({ + Modal: ({ + children, + open, + title, + }: { + children: React.ReactNode; + open: boolean; + title: string; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +vi.mock("@/components/ui/entities", () => ({ + EntityInfo: ({ + entityAlias, + entityId, + }: { + entityAlias?: string; + entityId?: string; + }) => <>{entityAlias || entityId}, +})); + +vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ + AccountsSelector: ({ + providers, + onBatchChange, + selectedValues, + id, + }: { + providers: { id: string; attributes: { alias: string; uid: string } }[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + id?: string; + }) => ( +
+ + +
+ ), +})); + +import { LaunchScanModal } from "./launch-scan-modal"; + +const provider = { + id: "provider-1", + type: "providers" as const, + attributes: { + provider: "aws" as const, + uid: "123456789012", + alias: "Production", + status: "completed" as const, + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, +}; + +describe("LaunchScanModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("shows a searchable provider selector", () => { + render( + , + ); + + expect(screen.getByPlaceholderText("Search accounts...")).toBeVisible(); + }); + + it("submits alias as scanName so the API stores it as the scan alias", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.type(screen.getByLabelText("Alias"), "Production audit"); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled()); + + const formData = scanOnDemandMock.mock.calls[0][0] as FormData; + expect(formData.get("providerId")).toBe(provider.id); + expect(formData.get("scanName")).toBe("Production audit"); + expect(formData.get("scanNote")).toBeNull(); + }); + + it("does not show the old scan note label", () => { + render( + , + ); + + expect(screen.queryByLabelText("Scan Note")).not.toBeInTheDocument(); + expect(screen.queryByText("Scan Note (optional)")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx new file mode 100644 index 0000000000..afe4a2b9cf --- /dev/null +++ b/ui/components/scans/launch-scan-modal.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { CloudCog, Rocket } from "lucide-react"; +import { useRouter } from "next/navigation"; +import type { FormEvent } from "react"; +import { useState } from "react"; + +import { scanOnDemand } from "@/actions/scans"; +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { FormButtons } from "@/components/ui/form"; +import { toast } from "@/components/ui/toast"; +import type { ProviderProps } from "@/types/providers"; + +interface LaunchScanModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + providers: ProviderProps[]; +} + +export function LaunchScanModal({ + open, + onOpenChange, + providers, +}: LaunchScanModalProps) { + const router = useRouter(); + const [providerId, setProviderId] = useState(""); + const [scanAlias, setScanAlias] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const closeModal = () => { + setProviderId(""); + setScanAlias(""); + setError(null); + onOpenChange(false); + }; + + const launchScan = async (event: FormEvent) => { + event.preventDefault(); + + if (!providerId) { + setError("Select a provider to launch a scan."); + return; + } + + setSubmitting(true); + setError(null); + + const formData = new FormData(); + formData.set("providerId", providerId); + if (scanAlias.trim()) { + formData.set("scanName", scanAlias.trim()); + } + + const result = await scanOnDemand(formData); + setSubmitting(false); + + if (result?.error) { + setError(String(result.error)); + return; + } + + toast({ + title: "Scan launched", + description: "The scan was launched successfully.", + }); + closeModal(); + router.refresh(); + }; + + return ( + { + if (!nextOpen) closeModal(); + else onOpenChange(true); + }} + title="Launch A Scan" + size="xl" + className="gap-8" + > +
+
+ + + Select the provider you would like to scan + +
+ + + Providers + setProviderId(values.at(-1) ?? "")} + selectedValues={providerId ? [providerId] : []} + closeOnSelect + /> + + + + Alias (optional) + setScanAlias(event.target.value)} + /> + + + {error && {error}} + + } + /> + +
+ ); +} diff --git a/ui/components/scans/launch-workflow/index.ts b/ui/components/scans/launch-workflow/index.ts deleted file mode 100644 index 4f8a794d33..0000000000 --- a/ui/components/scans/launch-workflow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./launch-scan-workflow-form"; -export * from "./select-scan-provider"; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx deleted file mode 100644 index 6ee64724ba..0000000000 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { AnimatePresence, motion } from "framer-motion"; -import { useForm, useWatch } from "react-hook-form"; -import * as z from "zod"; - -import { scanOnDemand } from "@/actions/scans"; -import { RocketIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { CustomInput } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { toast } from "@/components/ui/toast"; -import { onDemandScanFormSchema, ScanProviderInfo } from "@/types"; - -import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling"; -import { SelectScanProvider } from "./select-scan-provider"; - -export const LaunchScanWorkflow = ({ - providers, -}: { - providers: ScanProviderInfo[]; -}) => { - const formSchema = z.object({ - ...onDemandScanFormSchema().shape, - scanName: z - .union([ - z - .string() - .min(3, "Must be at least 3 characters") - .max(32, "Must not exceed 32 characters"), - z.literal(""), - ]) - .optional(), - }); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId: "", - scanName: "", - scannerArgs: undefined, - }, - }); - - const providerId = useWatch({ control: form.control, name: "providerId" }); - const hasProviderSelected = Boolean(providerId); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: z.infer) => { - const formValues = { ...values }; - - const formData = new FormData(); - - // Loop through form values and add to formData - Object.entries(formValues).forEach( - ([key, value]) => - value !== undefined && - formData.append( - key, - typeof value === "object" ? JSON.stringify(value) : value, - ), - ); - - const data = await scanOnDemand(formData); - - if (data?.error) { - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: data.error, - }); - } else { - toast({ - title: "Success!", - description: "The scan was launched successfully.", - }); - // Reset form after successful submission - form.reset(); - // Notify the scans table to refresh and pick up the new scan - window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT)); - } - }; - - return ( -
- -
- -
- - {hasProviderSelected && ( - <> -
- - - - - - - -
- - )} -
-
- - ); -}; diff --git a/ui/components/scans/launch-workflow/select-scan-provider.tsx b/ui/components/scans/launch-workflow/select-scan-provider.tsx deleted file mode 100644 index 9d8be12e3e..0000000000 --- a/ui/components/scans/launch-workflow/select-scan-provider.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Control, FieldPath, FieldValues } from "react-hook-form"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/shadcn"; -import { EntityInfo } from "@/components/ui/entities"; -import { FormControl, FormField, FormMessage } from "@/components/ui/form"; -import { ScanProviderInfo } from "@/types"; - -interface SelectScanProviderProps< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> { - providers: ScanProviderInfo[]; - control: Control; - name: TName; -} - -export const SelectScanProvider = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - providers, - control, - name, -}: SelectScanProviderProps) => { - return ( - { - const selectedItem = providers.find( - (item) => item.providerId === field.value, - ); - - return ( -
- - Select a provider to launch a scan - - - - - -
- ); - }} - /> - ); -}; diff --git a/ui/components/scans/scans-launch-section.test.tsx b/ui/components/scans/scans-launch-section.test.tsx deleted file mode 100644 index 7abee44117..0000000000 --- a/ui/components/scans/scans-launch-section.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { ScansLaunchSection } from "./scans-launch-section"; - -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: ({ open }: { open: boolean }) => - open ?
Provider wizard
: null, -})); - -vi.mock("@/components/scans/launch-workflow", () => ({ - LaunchScanWorkflow: () =>
Launch scan workflow
, -})); - -vi.mock("@/components/scans/no-providers-connected", () => ({ - NoProvidersConnected: () =>
No providers connected
, -})); - -vi.mock("@/components/ui/custom/custom-banner", () => ({ - CustomBanner: ({ title }: { title: string }) =>
{title}
, -})); - -const connectedProvider = { - providerId: "provider-1", - alias: "Production", - providerType: "aws", - uid: "123456789012", - connected: true, -}; - -describe("ScansLaunchSection", () => { - it("should keep the provider wizard open when providers data refreshes after adding the first provider", async () => { - // Given - const user = userEvent.setup(); - const { rerender } = render( - , - ); - - // When - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - rerender( - , - ); - - // Then - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); - expect(screen.getByText("Launch scan workflow")).toBeInTheDocument(); - }); -}); diff --git a/ui/components/scans/scans-launch-section.tsx b/ui/components/scans/scans-launch-section.tsx deleted file mode 100644 index f683155a21..0000000000 --- a/ui/components/scans/scans-launch-section.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import { ProviderWizardModal } from "@/components/providers/wizard"; -import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; -import { NoProvidersAdded } from "@/components/scans/no-providers-added"; -import { NoProvidersConnected } from "@/components/scans/no-providers-connected"; -import { CustomBanner } from "@/components/ui/custom/custom-banner"; -import { ScanProviderInfo } from "@/types"; - -interface ScansLaunchSectionProps { - providers: ScanProviderInfo[]; - hasManageScansPermission: boolean; - thereIsNoProviders: boolean; - thereIsNoProvidersConnected: boolean; -} - -export function ScansLaunchSection({ - providers, - hasManageScansPermission, - thereIsNoProviders, - thereIsNoProvidersConnected, -}: ScansLaunchSectionProps) { - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); - - return ( - <> - {thereIsNoProviders ? ( - setIsProviderWizardOpen(true)} /> - ) : !hasManageScansPermission ? ( - - ) : thereIsNoProvidersConnected ? ( - - ) : ( - - )} - - - ); -} diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx new file mode 100644 index 0000000000..75037c09e9 --- /dev/null +++ b/ui/components/scans/scans-page-shell.test.tsx @@ -0,0 +1,241 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ScansPageShell } from "./scans-page-shell"; + +const { pushMock, searchParamsValue } = vi.hoisted(() => ({ + pushMock: vi.fn(), + searchParamsValue: { current: "" }, +})); + +const { accountsSelectorSpy, providerTypeSelectorSpy } = vi.hoisted(() => ({ + accountsSelectorSpy: vi.fn(), + providerTypeSelectorSpy: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/scans", + useRouter: () => ({ + push: pushMock, + }), + useSearchParams: () => new URLSearchParams(searchParamsValue.current), +})); + +vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ + AccountsSelector: (props: unknown) => { + accountsSelectorSpy(props); + return
Shared accounts selector
; + }, +})); + +vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({ + ProviderTypeSelector: (props: unknown) => { + providerTypeSelectorSpy(props); + return
Shared provider type selector
; + }, +})); + +vi.mock("./launch-scan-modal", () => ({ + LaunchScanModal: ({ open }: { open: boolean }) => + open ?
Launch scan
: null, +})); + +vi.mock("@/components/providers/muted-findings-config-button", () => ({ + MutedFindingsConfigButton: () => Configure Mutelist, +})); + +const providers = [ + { + id: "provider-1", + type: "providers" as const, + attributes: { + provider: "aws" as const, + uid: "123456789012", + alias: "Production", + status: "completed" as const, + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, +]; + +describe("ScansPageShell", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + searchParamsValue.current = ""; + }); + + it("does not render an imported findings tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
Scans table
+
, + ); + + expect( + screen.queryByRole("tab", { name: /imported findings/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /import findings/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("uses the shared provider selectors from Findings for scan filters", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
Scans table
+
, + ); + + expect( + screen.getByText("Shared provider type selector"), + ).toBeInTheDocument(); + expect(screen.getByText("Shared accounts selector")).toBeInTheDocument(); + expect(providerTypeSelectorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + providers, + selectedValues: [], + }), + ); + expect(accountsSelectorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + providers, + filterKey: "provider_uid__in", + selectedValues: [], + selectedProviderTypes: [], + }), + ); + }); + + it("clears the active sort when switching tabs", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=active&sort=trigger"; + const user = userEvent.setup(); + + render( + +
Scans table
+
, + ); + + await user.click(screen.getByRole("tab", { name: /completed/i })); + + expect(pushMock).toHaveBeenCalled(); + const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string; + expect(calledUrl).toContain("tab=completed"); + expect(calledUrl).not.toContain("sort="); + }); + + it("uses a generic type filter label in Cloud", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + render( + +
Scans table
+
, + ); + + expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible(); + }); + + it("keeps launch scan with filters and mutelist with tabs", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
Scans table
+
, + ); + + expect( + screen.getByRole("group", { name: /scan filters/i }), + ).toContainElement(screen.getByRole("button", { name: /launch scan/i })); + expect( + screen.getByRole("group", { name: /scan filters/i }), + ).not.toContainElement( + screen.getByRole("link", { name: /configure mutelist/i }), + ); + expect(screen.getByRole("group", { name: /scan tabs/i })).toContainElement( + screen.getByRole("link", { name: /configure mutelist/i }), + ); + }); + + it("shows the status filter only on the completed tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=completed"; + + render( + +
Scans table
+
, + ); + + expect( + screen.getByRole("combobox", { name: /all statuses/i }), + ).toBeVisible(); + }); + + it("hides the status filter outside of the completed tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
Scans table
+
, + ); + + expect( + screen.queryByRole("combobox", { name: /all statuses/i }), + ).not.toBeInTheDocument(); + }); + + it("clears status filter when switching scan tabs", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed"; + const user = userEvent.setup(); + + render( + +
Scans table
+
, + ); + + await user.click(screen.getByRole("tab", { name: /active/i })); + + const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string; + expect(calledUrl).toContain("tab=active"); + expect(calledUrl).not.toContain("filter%5Bstate__in%5D"); + }); +}); diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx new file mode 100644 index 0000000000..cd2c9d73fe --- /dev/null +++ b/ui/components/scans/scans-page-shell.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useState } from "react"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import type { ProviderProps } from "@/types/providers"; + +import { LaunchScanModal } from "./launch-scan-modal"; +import { + getScanJobsTab, + getScanStatusFilterOptions, + getScanTriggerFilterOptions, + SCAN_JOBS_TAB, + SCAN_TAB_LABELS, + type ScanJobsTab, +} from "./scans-table.utils"; + +interface ScansPageShellProps { + providers: ProviderProps[]; + hasManageScansPermission: boolean; + children: ReactNode; +} + +const ALL_VALUE = "all"; + +function getFirstFilterValue(value: string | null): string { + return value?.split(",")[0] || ALL_VALUE; +} + +function getFilterValues(value: string | null): string[] { + return value?.split(",").filter(Boolean) ?? []; +} + +export function ScansPageShell({ + providers, + hasManageScansPermission, + children, +}: ScansPageShellProps) { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [launchOpen, setLaunchOpen] = useState(false); + const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined); + const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment); + const statusFilterOptions = getScanStatusFilterOptions(activeTab); + const showStatusFilter = activeTab === SCAN_JOBS_TAB.COMPLETED; + const selectedProviderTypes = getFilterValues( + searchParams.get("filter[provider_type__in]"), + ); + const selectedProviderUids = getFilterValues( + searchParams.get("filter[provider_uid__in]"), + ); + const scheduleType = getFirstFilterValue(searchParams.get("filter[trigger]")); + const scanStatus = getFirstFilterValue( + searchParams.get("filter[state__in]") ?? searchParams.get("filter[state]"), + ); + + const updateParams = (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + Object.entries(updates).forEach(([key, value]) => { + if (!value || value === ALL_VALUE) params.delete(key); + else params.set(key, value); + }); + + params.delete("page"); + params.delete("scanId"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + const setTab = (tab: string) => { + updateParams({ + tab, + sort: null, + "filter[state]": null, + "filter[state__in]": null, + }); + }; + + const setFilterValues = (filterKey: string, values: string[]) => { + updateParams({ + [`filter[${filterKey}]`]: values.length > 0 ? values.join(",") : null, + }); + }; + + const launchDisabled = !hasManageScansPermission || providers.length === 0; + + return ( +
+
+
+ + + + + + + {showStatusFilter && ( + + )} +
+ +
+ +
+
+ + +
+ + {Object.values(SCAN_JOBS_TAB).map((tab) => ( + + {SCAN_TAB_LABELS[tab as ScanJobsTab]} + + ))} + +
+ +
+
+ + {children} + +
+ + +
+ ); +} diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx new file mode 100644 index 0000000000..8a488309b3 --- /dev/null +++ b/ui/components/scans/scans-providers-empty-state.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; + +vi.mock("@/components/providers/wizard", () => ({ + ProviderWizardModal: ({ open }: { open: boolean }) => + open ?
Provider wizard
: null, +})); + +vi.mock("./no-providers-connected", () => ({ + NoProvidersConnected: () =>
No Connected Providers
, +})); + +describe("ScansProvidersEmptyState", () => { + it("shows the add provider message and opens the provider wizard", async () => { + const user = userEvent.setup(); + + render(); + + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + }); + + it("shows the no connected providers message", () => { + render(); + + expect(screen.getByText("No Connected Providers")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/scans-providers-empty-state.tsx b/ui/components/scans/scans-providers-empty-state.tsx new file mode 100644 index 0000000000..153d8071c3 --- /dev/null +++ b/ui/components/scans/scans-providers-empty-state.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; + +import { ProviderWizardModal } from "@/components/providers/wizard"; + +import { NoProvidersAdded } from "./no-providers-added"; +import { NoProvidersConnected } from "./no-providers-connected"; + +interface ScansProvidersEmptyStateProps { + thereIsNoProviders: boolean; +} + +export function ScansProvidersEmptyState({ + thereIsNoProviders, +}: ScansProvidersEmptyStateProps) { + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + + return ( + <> + {thereIsNoProviders ? ( + setIsProviderWizardOpen(true)} /> + ) : ( + + )} + + + ); +} diff --git a/ui/components/scans/scans-table.utils.test.ts b/ui/components/scans/scans-table.utils.test.ts new file mode 100644 index 0000000000..c447d254f2 --- /dev/null +++ b/ui/components/scans/scans-table.utils.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import type { ScanAttributes, ScanProps } from "@/types"; + +import { + formatScanDuration, + getScanAlias, + getScanFindingsSummary, + getScanJobsTab, + getScanJobsTabFilters, + getScanScheduleLabel, + getScanStatusLabel, + getScanTriggerFilterOptions, + SCAN_JOBS_TAB, +} from "./scans-table.utils"; + +const makeScan = (name: string | null): ScanProps => ({ + type: "scans", + id: "scan-1", + attributes: { + name: name ?? "", + trigger: "manual", + state: "completed", + unique_resource_count: 0, + progress: 100, + scanner_args: null, + duration: 0, + started_at: "", + inserted_at: "", + completed_at: "", + scheduled_at: "", + next_scan_at: "", + }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + task: { data: { type: "tasks", id: "task-1" } }, + }, +}); + +describe("scans-table.utils", () => { + it("falls back to active tab for unknown tab values", () => { + expect(getScanJobsTab("unknown")).toBe(SCAN_JOBS_TAB.ACTIVE); + expect(getScanJobsTab(SCAN_JOBS_TAB.COMPLETED)).toBe( + SCAN_JOBS_TAB.COMPLETED, + ); + }); + + it("maps scan job tabs to the state filters expected by the API", () => { + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE)).toEqual({ + "filter[state__in]": "available,executing", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED)).toEqual({ + "filter[state__in]": "completed,failed,cancelled", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.SCHEDULED)).toEqual({ + "filter[state__in]": "scheduled", + }); + }); + + it("narrows tab state filters when a matching status is selected", () => { + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed")).toEqual({ + "filter[state__in]": "failed", + }); + expect( + getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed,cancelled"), + ).toEqual({ + "filter[state__in]": "failed,cancelled", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE, "failed")).toEqual({ + "filter[state__in]": "available,executing", + }); + }); + + it("formats scan labels and durations for table display", () => { + expect(getScanAlias(makeScan(""))).toBe("-"); + expect(getScanAlias(makeScan("Daily scheduled scan"))).toBe( + "scheduled scan", + ); + expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan"); + expect(formatScanDuration(73)).toBe("1 min 13 sec"); + expect(formatScanDuration(null)).toBe("-"); + }); + + it("maps trigger and state values to product labels", () => { + expect(getScanScheduleLabel("manual")).toBe("Manual"); + expect(getScanScheduleLabel("scheduled")).toBe("Scheduled"); + expect(getScanScheduleLabel("imported")).toBe("Imported"); + expect(getScanStatusLabel("available")).toBe("Queued"); + expect(getScanStatusLabel("completed")).toBe("Completed"); + }); + + it("includes imported in the trigger filter only for Cloud", () => { + expect(getScanTriggerFilterOptions(false)).toEqual([ + { value: "all", label: "All Types" }, + { value: "manual", label: "Manual" }, + { value: "scheduled", label: "Scheduled" }, + ]); + expect(getScanTriggerFilterOptions(true)).toEqual([ + { value: "all", label: "All Types" }, + { value: "manual", label: "Manual" }, + { value: "scheduled", label: "Scheduled" }, + { value: "imported", label: "Imported" }, + ]); + }); + + it("reads findings summary from root or nested API fields", () => { + expect( + getScanFindingsSummary({ + fail: 2, + pass: 3, + fail_new: 1, + } as unknown as ScanAttributes), + ).toEqual({ fail: 2, pass: 3, failNew: 1 }); + + expect( + getScanFindingsSummary({ + findings: { + failed_findings: 4, + passed_findings: 8, + new_passed_findings: 2, + }, + } as unknown as ScanAttributes), + ).toEqual({ fail: 4, pass: 8, passNew: 2 }); + + expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull(); + }); +}); diff --git a/ui/components/scans/scans-table.utils.ts b/ui/components/scans/scans-table.utils.ts new file mode 100644 index 0000000000..a367fd8491 --- /dev/null +++ b/ui/components/scans/scans-table.utils.ts @@ -0,0 +1,219 @@ +import { + SCAN_STATE, + SCAN_TRIGGER, + type ScanAttributes, + type ScanProps, + type ScanState, + type ScanTrigger, +} from "@/types"; + +export const SCAN_JOBS_TAB = { + ACTIVE: "active", + COMPLETED: "completed", + SCHEDULED: "scheduled", +} as const; + +export type ScanJobsTab = (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]; + +export const DEFAULT_SCAN_JOBS_TAB: ScanJobsTab = SCAN_JOBS_TAB.ACTIVE; + +export interface ScanFindingsSummary { + fail: number; + pass: number; + failNew?: number; + passNew?: number; +} + +export const SCAN_TAB_LABELS: Record = { + [SCAN_JOBS_TAB.ACTIVE]: "Active", + [SCAN_JOBS_TAB.COMPLETED]: "Completed", + [SCAN_JOBS_TAB.SCHEDULED]: "Scheduled", +}; + +const SCAN_JOBS_TAB_FILTERS: Record> = { + [SCAN_JOBS_TAB.ACTIVE]: { + "filter[state__in]": `${SCAN_STATE.AVAILABLE},${SCAN_STATE.EXECUTING}`, + }, + [SCAN_JOBS_TAB.COMPLETED]: { + "filter[state__in]": [ + SCAN_STATE.COMPLETED, + SCAN_STATE.FAILED, + SCAN_STATE.CANCELLED, + ].join(","), + }, + [SCAN_JOBS_TAB.SCHEDULED]: { + "filter[state__in]": SCAN_STATE.SCHEDULED, + }, +}; + +export const SCAN_STATE_FILTER_KEYS = [ + "filter[state]", + "filter[state__in]", +] as const; + +const ALL_VALUE = "all"; +const SCAN_JOBS_TAB_STATES: Record = { + [SCAN_JOBS_TAB.ACTIVE]: [SCAN_STATE.AVAILABLE, SCAN_STATE.EXECUTING], + [SCAN_JOBS_TAB.COMPLETED]: [ + SCAN_STATE.COMPLETED, + SCAN_STATE.FAILED, + SCAN_STATE.CANCELLED, + ], + [SCAN_JOBS_TAB.SCHEDULED]: [SCAN_STATE.SCHEDULED], +}; + +export interface ScanTriggerFilterOption { + value: typeof ALL_VALUE | ScanTrigger; + label: string; +} + +export interface ScanStatusFilterOption { + value: typeof ALL_VALUE | ScanState; + label: string; +} + +export function getScanTriggerFilterOptions( + isCloudEnvironment: boolean, +): ScanTriggerFilterOption[] { + const options: ScanTriggerFilterOption[] = [ + { value: ALL_VALUE, label: "All Types" }, + { value: SCAN_TRIGGER.MANUAL, label: "Manual" }, + { value: SCAN_TRIGGER.SCHEDULED, label: "Scheduled" }, + ]; + + if (isCloudEnvironment) { + options.push({ value: SCAN_TRIGGER.IMPORTED, label: "Imported" }); + } + + return options; +} + +export function isScanStateFilterKey(key: string): boolean { + return SCAN_STATE_FILTER_KEYS.some((filterKey) => filterKey === key); +} + +function parseStateFilter(value?: string | string[]): ScanState[] { + const rawValue = Array.isArray(value) ? value.join(",") : value; + if (!rawValue || rawValue === ALL_VALUE) return []; + + return rawValue + .split(",") + .filter((item): item is ScanState => + Object.values(SCAN_STATE).includes(item as ScanState), + ); +} + +export function getScanJobsTab(value?: string | string[]): ScanJobsTab { + const rawValue = Array.isArray(value) ? value[0] : value; + const tabs = Object.values(SCAN_JOBS_TAB); + + return tabs.includes(rawValue as ScanJobsTab) + ? (rawValue as ScanJobsTab) + : DEFAULT_SCAN_JOBS_TAB; +} + +export function getScanJobsTabFilters( + tab: ScanJobsTab, + stateFilter?: string | string[], +): Record { + const selectedStates = parseStateFilter(stateFilter); + const allowedStates = SCAN_JOBS_TAB_STATES[tab]; + const matchingStates = selectedStates.filter((state) => + allowedStates.includes(state), + ); + + if (matchingStates.length === 0) return { ...SCAN_JOBS_TAB_FILTERS[tab] }; + + return { "filter[state__in]": matchingStates.join(",") }; +} + +export function getScanAlias(scan: ScanProps): string { + const name = scan.attributes.name?.trim(); + if (!name) return "-"; + if (name === "Daily scheduled scan") return "scheduled scan"; + return name; +} + +export function formatScanDuration(duration?: number | null): string { + if (duration === null || duration === undefined || duration < 0) return "-"; + + const totalSeconds = Math.round(duration); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours > 0) return `${hours}h ${remainingMinutes}m ${seconds}s`; + if (minutes > 0) return `${minutes} min ${seconds} sec`; + return `${seconds} sec`; +} + +export function getScanScheduleLabel(trigger?: ScanTrigger | string): string { + if (trigger === "scheduled") return "Scheduled"; + if (trigger === "manual") return "Manual"; + if (trigger === "imported") return "Imported"; + return "-"; +} + +export function getScanStatusLabel(state?: ScanState | string): string { + if (state === "available") return "Queued"; + if (!state) return "-"; + return state.charAt(0).toUpperCase() + state.slice(1); +} + +export function getScanStatusFilterOptions( + tab: ScanJobsTab, +): ScanStatusFilterOption[] { + return [ + { value: ALL_VALUE, label: "All Statuses" }, + ...SCAN_JOBS_TAB_STATES[tab].map((state) => ({ + value: state, + label: getScanStatusLabel(state), + })), + ]; +} + +function getNumericValue( + source: Record, + keys: string[], +): number | undefined { + for (const key of keys) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + + return undefined; +} + +export function getScanFindingsSummary( + attributes: ScanAttributes, +): ScanFindingsSummary | null { + const root = attributes as unknown as Record; + const nested = + typeof root.findings === "object" && root.findings !== null + ? (root.findings as Record) + : {}; + const source = { ...root, ...nested }; + + const fail = getNumericValue(source, [ + "fail", + "failed", + "failed_findings", + "fail_findings", + ]); + const pass = getNumericValue(source, [ + "pass", + "passed", + "passed_findings", + "pass_findings", + ]); + + if (fail === undefined || pass === undefined) return null; + + return { + fail, + pass, + failNew: getNumericValue(source, ["fail_new", "new_failed_findings"]), + passNew: getNumericValue(source, ["pass_new", "new_passed_findings"]), + }; +} diff --git a/ui/components/scans/table/scans/column-get-scans.test.ts b/ui/components/scans/table/scans/column-get-scans.test.ts deleted file mode 100644 index 88757c5d98..0000000000 --- a/ui/components/scans/table/scans/column-get-scans.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -describe("column-get-scans", () => { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(currentDir, "column-get-scans.tsx"); - const source = readFileSync(filePath, "utf8"); - - it("links scan findings to the historical finding-groups filters", () => { - expect(source).toContain("filter[scan]="); - expect(source).toContain("filter[inserted_at]="); - expect(source).not.toContain("filter[scan__in]"); - }); - - it("links the findings filter against the scan's completed_at (what the backend expects)", () => { - expect(source).toMatch(/attributes:\s*{\s*completed_at\s*}/); - expect(source).toMatch(/toLocalDateString\(completed_at\)/); - }); -}); diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx deleted file mode 100644 index bacb1894f2..0000000000 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { useRouter, useSearchParams } from "next/navigation"; - -import { InfoIcon } from "@/components/icons"; -import { TableLink } from "@/components/ui/custom"; -import { DateWithTime, EntityInfo } from "@/components/ui/entities"; -import { TriggerSheet } from "@/components/ui/sheet"; -import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; -import { toLocalDateString } from "@/lib/date-utils"; -import { ProviderType, ScanProps } from "@/types"; - -import { TriggerIcon } from "../../trigger-icon"; -import { DataTableRowActions } from "./data-table-row-actions"; -import { DataTableRowDetails } from "./data-table-row-details"; - -const getScanData = (row: { original: ScanProps }) => { - return row.original; -}; - -const ScanDetailsCell = ({ row }: { row: any }) => { - const router = useRouter(); - const searchParams = useSearchParams(); - const scanId = searchParams.get("scanId"); - const isOpen = scanId === row.original.id; - const scanState = row.original.attributes?.state; - const isExecuting = scanState === "executing" || scanState === "available"; - - const handleOpenChange = (open: boolean) => { - if (isExecuting) return; - - const params = new URLSearchParams(searchParams.toString()); - - if (open) { - params.set("scanId", row.original.id); - } else { - params.delete("scanId"); - } - - router.push(`?${params.toString()}`, { scroll: false }); - }; - - return ( -
- - } - title="Scan Details" - description="View the scan details" - open={isOpen} - onOpenChange={handleOpenChange} - > - {isOpen && } - -
- ); -}; - -export const ColumnGetScans: ColumnDef[] = [ - { - id: "moreInfo", - header: ({ column }) => ( - - ), - cell: ({ row }) => , - enableSorting: false, - }, - { - accessorKey: "cloudProvider", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const providerInfo = row.original.providerInfo; - - if (!providerInfo) { - return No provider info; - } - - const { provider, uid, alias } = providerInfo; - - return ( - - ); - }, - enableSorting: false, - }, - - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { state }, - } = getScanData(row); - return ( -
- -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "findings", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - id, - attributes: { completed_at }, - } = getScanData(row); - const scanState = row.original.attributes?.state; - // Source is `completed_at` (scan finish time) because findings are - // persisted when the scan ends — that's when their `inserted_at` is - // written. The URL key stays `filter[inserted_at]` because the findings - // table is partitioned by the finding's `inserted_at` date; this filter - // is the partition hint the backend uses to avoid scanning every - // partition. Names differ by design: scan.completed_at ≈ finding.inserted_at. - const scanDate = toLocalDateString(completed_at); - return ( - - ); - }, - enableSorting: false, - }, - { - accessorKey: "compliance", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { id } = getScanData(row); - const scanState = row.original.attributes?.state; - return ( - - ); - }, - enableSorting: false, - }, - { - accessorKey: "resources", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { unique_resource_count }, - } = getScanData(row); - return ( -
- {unique_resource_count} -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "started_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { started_at }, - } = getScanData(row); - - return ( -
- -
- ); - }, - enableSorting: false, - }, - { - accessorKey: "scheduled_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { scheduled_at }, - } = getScanData(row); - return ; - }, - enableSorting: false, - }, - { - accessorKey: "completed_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { completed_at }, - } = getScanData(row); - return ; - }, - }, - { - accessorKey: "trigger", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { trigger }, - } = getScanData(row); - return ( -
- -
- ); - }, - }, - { - accessorKey: "scanName", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { name }, - } = getScanData(row); - - if (!name || name.length === 0) { - return -; - } - return ( -
- - {name === "Daily scheduled scan" ? "scheduled scan" : name} - -
- ); - }, - }, - { - id: "actions", - header: ({ column }) => , - cell: ({ row }) => { - return ; - }, - enableSorting: false, - }, -]; diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx deleted file mode 100644 index a9e0cae118..0000000000 --- a/ui/components/scans/table/scans/data-table-row-actions.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { Row } from "@tanstack/react-table"; -import { Download, Pencil } from "lucide-react"; -import { useState } from "react"; - -import { - ActionDropdown, - ActionDropdownItem, -} from "@/components/shadcn/dropdown"; -import { Modal } from "@/components/shadcn/modal"; -import { useToast } from "@/components/ui"; -import { downloadScanZip } from "@/lib/helper"; - -import { EditScanForm } from "../../forms"; - -interface DataTableRowActionsProps { - row: Row; -} - -export function DataTableRowActions({ - row, -}: DataTableRowActionsProps) { - const { toast } = useToast(); - const [isEditOpen, setIsEditOpen] = useState(false); - const scanId = (row.original as { id: string }).id; - const scanName = (row.original as any).attributes?.name; - const scanState = (row.original as any).attributes?.state; - - return ( - <> - - - - -
- - } - label="Download .zip" - description="Available only for completed scans" - onSelect={() => downloadScanZip(scanId, toast)} - disabled={scanState !== "completed"} - /> - } - label="Edit Scan Name" - onSelect={() => setIsEditOpen(true)} - /> - -
- - ); -} diff --git a/ui/components/scans/table/scans/data-table-row-details.tsx b/ui/components/scans/table/scans/data-table-row-details.tsx deleted file mode 100644 index fd17836e60..0000000000 --- a/ui/components/scans/table/scans/data-table-row-details.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -import { getProvider } from "@/actions/providers"; -import { getScan } from "@/actions/scans"; -import { getTask } from "@/actions/task"; -import { ScanDetail } from "@/components/scans/table"; -import { checkTaskStatus } from "@/lib"; -import { ScanProps } from "@/types"; - -import { SkeletonScanDetail } from "./skeleton-scan-detail"; - -export const DataTableRowDetails = ({ entityId }: { entityId: string }) => { - const [scanDetails, setScanDetails] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchScanDetails = async () => { - try { - const result = await getScan(entityId); - - const taskId = result.data.relationships.task?.data?.id; - const providerId = result.data.relationships.provider?.data?.id; - - let providerDetails = null; - if (providerId) { - const formData = new FormData(); - formData.append("id", providerId); - const providerResult = await getProvider(formData); - providerDetails = providerResult.data; - } - - if (taskId) { - const taskResult = await checkTaskStatus(taskId); - - if (taskResult.completed !== undefined) { - const task = await getTask(taskId); - setScanDetails({ - ...result.data, - taskDetails: task.data, - providerDetails: providerDetails, - }); - } - } else { - setScanDetails({ - ...result.data, - providerDetails: providerDetails, - }); - } - } catch (error) { - console.error("Error in fetchScanDetails:", error); - } finally { - setIsLoading(false); - } - }; - - fetchScanDetails(); - }, [entityId]); - - if (isLoading) { - return ; - } - - if (!scanDetails) { - return
No scan details available
; - } - - return ; -}; diff --git a/ui/components/scans/table/scans/index.ts b/ui/components/scans/table/scans/index.ts index 18c3035f7d..6c01959391 100644 --- a/ui/components/scans/table/scans/index.ts +++ b/ui/components/scans/table/scans/index.ts @@ -1,4 +1,3 @@ -export * from "./column-get-scans"; -export * from "./data-table-row-actions"; -export * from "./data-table-row-details"; -export * from "./scans-table-with-polling"; +export * from "./scan-jobs-columns"; +export * from "./scan-jobs-row-actions"; +export * from "./scan-jobs-table"; diff --git a/ui/components/scans/table/scans/scan-jobs-columns.test.tsx b/ui/components/scans/table/scans/scan-jobs-columns.test.tsx new file mode 100644 index 0000000000..9c10df1a88 --- /dev/null +++ b/ui/components/scans/table/scans/scan-jobs-columns.test.tsx @@ -0,0 +1,172 @@ +import type { CellContext, HeaderContext } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ScanProps } from "@/types"; + +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Progress: () =>
, +})); + +vi.mock("@/components/ui/entities", () => ({ + DateWithTime: () =>