Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
30d9f86
feat(ui): add new scan jobs view
alejandrobailo May 20, 2026
e28a701
fix(ui): prevent wide select content clipping
alejandrobailo May 20, 2026
e8bfa4f
feat(ui): add file upload dropzone
alejandrobailo May 20, 2026
ca1889d
refactor(ui): polish scan job modals
alejandrobailo May 20, 2026
3a0bd7d
fix(ui): refine scan jobs table states
alejandrobailo May 20, 2026
73881cc
fix(ui): preserve scan note on launch
alejandrobailo May 20, 2026
61a26d3
test(ui): prune low-value scan tests
alejandrobailo May 20, 2026
0312f2c
docs(ui): add scan jobs changelog entry
alejandrobailo May 20, 2026
98b3933
fix(ui): gate scan schedule editing to cloud
alejandrobailo May 20, 2026
64b78cb
fix(ui): restore scan findings link
alejandrobailo May 20, 2026
7eca46c
fix(ui): gate cloud-only scan actions
alejandrobailo May 20, 2026
2d15008
fix(ui): improve progress track contrast
alejandrobailo May 20, 2026
fa0b349
Merge branch 'master' into feat/PROWLER-1761-new-scans-view
alejandrobailo May 20, 2026
4b67d39
fix(ui): refine scans page controls
alejandrobailo May 21, 2026
c2ebe0d
fix(ui): remove scans table selection
alejandrobailo May 21, 2026
0b33b33
fix(ui): use trigger filter for imported scans
alejandrobailo May 21, 2026
821ced5
fix(ui): show scans provider empty state
alejandrobailo May 21, 2026
dd0ce71
chore(ui): remove legacy scans view dead code
alejandrobailo May 21, 2026
7e6afe5
feat(ui): add Edit Alias modal in scan jobs row actions
alejandrobailo May 21, 2026
938a846
refactor(ui): polish scan jobs columns and tabs
alejandrobailo May 21, 2026
cc17302
fix(ui): align expand-all toggle slot with cell placeholder
alejandrobailo May 21, 2026
ed76b3f
feat(ui): enable column sorting and hide Duration in scan jobs
alejandrobailo May 21, 2026
42f871e
fix(ui): reset sort when switching scan jobs tabs
alejandrobailo May 21, 2026
b4f9ca4
test(ui): align scans e2e page object with new launch scan modal
alejandrobailo May 21, 2026
a2866e9
test(ui): scope scans provider option to open popover
alejandrobailo May 21, 2026
85a4756
fix(ui): restore scan actions and filters
alejandrobailo May 22, 2026
5ec2410
fix(ui): update scan jobs table columns
alejandrobailo May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,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(
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
);

expect(
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-value", "123456789012");
});

it("disables select all when every account is already shown", () => {
render(<AccountsSelector providers={providers} />);

Expand Down
46 changes: 28 additions & 18 deletions ui/app/(prowler)/_overview/_components/accounts-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
Expand All @@ -59,6 +67,8 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
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
Expand Down Expand Up @@ -99,6 +109,8 @@ export function AccountsSelector({
onBatchChange,
selectedValues,
selectedProviderTypes,
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
id = "accounts-selector",
search = {
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
Expand All @@ -107,33 +119,38 @@ export function AccountsSelector({
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();

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);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);
params.delete(urlFilterKey);

if (ids.length > 0) {
params.set(filterKey, ids.join(","));
params.set(urlFilterKey, ids.join(","));
}
});
};

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 <span className="truncate">{name}</span>;
}
Expand All @@ -152,19 +169,12 @@ export function AccountsSelector({

return (
<div className="relative">
<label
htmlFor="accounts-selector"
className="sr-only"
id="accounts-label"
>
<label htmlFor={id} className="sr-only" id={labelId}>
Filter by provider account. {filterDescription}. Select one or more
accounts to view findings.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger
id="accounts-selector"
aria-labelledby="accounts-label"
>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
Expand Down Expand Up @@ -192,7 +202,7 @@ export function AccountsSelector({
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{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];
Expand All @@ -205,8 +215,8 @@ export function AccountsSelector({
].filter(Boolean);
return (
<MultiSelectItem
key={id}
value={id}
key={p.id}
value={value}
badgeLabel={displayName}
keywords={searchKeywords}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
Expand Down
160 changes: 42 additions & 118 deletions ui/app/(prowler)/scans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<ContentLayout title="Scans" icon="lucide:timer">
<>
<ScansLaunchSection
providers={providerInfo}
<ContentLayout title="Scan Jobs" icon="lucide:timer">
{thereIsNoProviders || thereIsNoProvidersConnected ? (
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
) : (
<ScansPageShell
providers={connectedProviders}
hasManageScansPermission={hasManageScansPermission}
thereIsNoProviders={thereIsNoProviders}
thereIsNoProvidersConnected={thereIsNoProvidersConnected}
/>
{!thereIsNoProviders && (
<div className="flex flex-col gap-6">
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
completedScans={completedScans}
/>
<div className="flex items-center justify-end">
<MutedFindingsConfigButton />
</div>
<Suspense fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</div>
)}
</>
>
<Suspense fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</ScansPageShell>
)}
</ContentLayout>
);
}
Expand All @@ -126,21 +61,23 @@ 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),
};

// Extract query from filters
const query = (filters["filter[search]"] as string) || "";

// Fetch scans data with provider information included
const scansData = await getScans({
query,
page,
Expand All @@ -158,19 +95,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,
Expand All @@ -182,11 +112,5 @@ const SSRDataTableScans = async ({
};
}) || [];

return (
<ScansTableWithPolling
initialData={expandedScansData}
initialMeta={meta}
searchParams={searchParams}
/>
);
return <ScanJobsTable data={expandedScansData} meta={meta} tab={tab} />;
};
Loading
Loading