mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): improve scan scheduling flows (#11684)
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.31.1] (Prowler v5.31.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Schedule Scans provider table and launch flows now use provider schedule fields, restore OSS daily scheduling, default to the next local scan hour, and clarify provider selection in launch scan [(#11684)](https://github.com/prowler-cloud/prowler/pull/11684)
|
||||
|
||||
---
|
||||
|
||||
## [1.31.0] (Prowler v5.31.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -147,9 +147,24 @@ describe("AccountsSelector", () => {
|
||||
placeholder: "Search Providers...",
|
||||
emptyMessage: "No Providers found.",
|
||||
});
|
||||
expect(screen.getByText("All Providers")).toBeInTheDocument();
|
||||
expect(screen.getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports contextual placeholder and empty-selection copy", () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
placeholder="Select a Provider"
|
||||
emptySelectionLabel="No provider selected"
|
||||
clearSelectionLabel="Clear provider selection"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select a Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("No provider selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows disabling search explicitly", () => {
|
||||
render(<AccountsSelector providers={providers} search={false} />);
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ interface AccountsSelectorBaseProps {
|
||||
id?: string;
|
||||
disabledValues?: string[];
|
||||
closeOnSelect?: boolean;
|
||||
placeholder?: string;
|
||||
emptySelectionLabel?: string;
|
||||
clearSelectionLabel?: string;
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
@@ -73,6 +76,9 @@ export function AccountsSelector({
|
||||
emptyMessage: "No Providers found.",
|
||||
},
|
||||
closeOnSelect = false,
|
||||
placeholder = "All Providers",
|
||||
emptySelectionLabel = "All selected",
|
||||
clearSelectionLabel = "Select All",
|
||||
}: AccountsSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
@@ -163,7 +169,7 @@ export function AccountsSelector({
|
||||
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
|
||||
>
|
||||
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
|
||||
{selectedLabel() || <MultiSelectValue placeholder={placeholder} />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{visibleProviders.length > 0 ? (
|
||||
@@ -187,7 +193,9 @@ export function AccountsSelector({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||
{selectedIds.length === 0
|
||||
? emptySelectionLabel
|
||||
: clearSelectionLabel}
|
||||
</div>
|
||||
{visibleProviders.map((p) => {
|
||||
const value = getProviderValue(p);
|
||||
|
||||
@@ -886,6 +886,103 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses provider schedule attributes as authoritative when scan_hour is null", async () => {
|
||||
// Given — provider-1 still has a materialized scheduled scan row, but the
|
||||
// provider payload says the schedule was removed.
|
||||
providersActionsMock.getProviders.mockResolvedValue({
|
||||
...providersResponse,
|
||||
data: [
|
||||
{
|
||||
...providersResponse.data[0],
|
||||
attributes: {
|
||||
...providersResponse.data[0].attributes,
|
||||
scan_enabled: true,
|
||||
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
scan_hour: null,
|
||||
scan_timezone: "UTC",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: null,
|
||||
next_scan_at: null,
|
||||
last_scan_at: null,
|
||||
},
|
||||
},
|
||||
providersResponse.data[1],
|
||||
],
|
||||
});
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
scansActionsMock.getScans.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
attributes: { trigger: "scheduled", state: "scheduled" },
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
schedulesActionsMock.getSchedules.mockResolvedValue({
|
||||
data: [buildSchedule("provider-1", { scan_hour: 9 })],
|
||||
});
|
||||
|
||||
// When
|
||||
const viewData = await loadProvidersAccountsViewData({
|
||||
searchParams: {} satisfies SearchParamsProps,
|
||||
isCloud: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
const providerRow = findProviderRow(viewData.rows, "provider-1");
|
||||
expect(providerRow?.hasSchedule).toBe(false);
|
||||
expect(providerRow?.scheduleSummary).toBeUndefined();
|
||||
expect(providerRow?.lastScanAt).toBeNull();
|
||||
});
|
||||
|
||||
it("builds provider schedule and last scan values from the provider payload", async () => {
|
||||
// Given
|
||||
providersActionsMock.getProviders.mockResolvedValue({
|
||||
...providersResponse,
|
||||
data: [
|
||||
{
|
||||
...providersResponse.data[0],
|
||||
attributes: {
|
||||
...providersResponse.data[0].attributes,
|
||||
scan_enabled: true,
|
||||
scan_frequency: SCHEDULE_FREQUENCY.MONTHLY,
|
||||
scan_hour: 8,
|
||||
scan_timezone: "Europe/Madrid",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: 24,
|
||||
next_scan_at: "2026-06-24T06:00:00Z",
|
||||
last_scan_at: "2026-06-23T06:00:00Z",
|
||||
},
|
||||
},
|
||||
providersResponse.data[1],
|
||||
],
|
||||
});
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
scansActionsMock.getScans.mockResolvedValue({ data: [] });
|
||||
schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" });
|
||||
|
||||
// When
|
||||
const viewData = await loadProvidersAccountsViewData({
|
||||
searchParams: {} satisfies SearchParamsProps,
|
||||
isCloud: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
const providerRow = findProviderRow(viewData.rows, "provider-1");
|
||||
expect(providerRow?.hasSchedule).toBe(true);
|
||||
expect(providerRow?.scheduleSummary?.cadence).toBe("Monthly on the 24th");
|
||||
expect(providerRow?.scheduleSummary?.nextScanAt).toBe(
|
||||
"2026-06-24T06:00:00Z",
|
||||
);
|
||||
expect(providerRow?.lastScanAt).toBe("2026-06-23T06:00:00Z");
|
||||
});
|
||||
|
||||
it("ignores paused or unconfigured schedules", async () => {
|
||||
// Given — provider-1 paused (disabled), provider-2 never configured.
|
||||
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
|
||||
@@ -913,8 +1010,10 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => {
|
||||
// Given — /schedules errors, but provider-1 has a materialized scheduled scan.
|
||||
it("does not infer provider schedules from materialized scans when /schedules is unavailable", async () => {
|
||||
// Given — /schedules errors, and provider-1 still has a materialized
|
||||
// scheduled scan. That scan is historical execution state, not schedule
|
||||
// configuration.
|
||||
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
scansActionsMock.getScans.mockResolvedValue({
|
||||
@@ -937,9 +1036,9 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
isCloud: false,
|
||||
});
|
||||
|
||||
// Then — scan-based path still flags the provider; no throw from the error.
|
||||
// Then — only provider scan_* fields or /schedules can mark a schedule.
|
||||
expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe(
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe(
|
||||
false,
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
listOrganizationUnitsSafe,
|
||||
} from "@/actions/organizations/organizations";
|
||||
import { getAllProviders, getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getSchedules } from "@/actions/schedules";
|
||||
import {
|
||||
extractFiltersAndQuery,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "@/lib/helper-filters";
|
||||
import {
|
||||
buildProviderScheduleSummary,
|
||||
buildScheduleAttributesFromProvider,
|
||||
buildSchedulesByProviderId,
|
||||
isScheduleConfigured,
|
||||
} from "@/lib/schedules";
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
ProvidersTableRow,
|
||||
ProvidersTableRowsInput,
|
||||
} from "@/types/providers-table";
|
||||
import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans";
|
||||
import { ScanScheduleSummary } from "@/types/scans";
|
||||
import { ScheduleAttributes } from "@/types/schedules";
|
||||
|
||||
const PROVIDERS_STATUS_MAPPING = [
|
||||
@@ -114,26 +114,6 @@ const createProviderGroupLookup = (
|
||||
return lookup;
|
||||
};
|
||||
|
||||
const ACTIVE_SCAN_STATES = new Set(["scheduled", "available", "executing"]);
|
||||
|
||||
const buildScheduledProviderIds = (scans: ScanProps[]): Set<string> => {
|
||||
const scheduled = new Set<string>();
|
||||
|
||||
for (const scan of scans) {
|
||||
if (
|
||||
scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED &&
|
||||
ACTIVE_SCAN_STATES.has(scan.attributes.state)
|
||||
) {
|
||||
const providerId = scan.relationships.provider?.data?.id;
|
||||
if (providerId) {
|
||||
scheduled.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
// A schedule is backed by the Provider row itself, so its `/schedules` entry
|
||||
// exists before the first scheduled Scan is materialized — only enabled,
|
||||
// configured ones carry a displayable cadence summary.
|
||||
@@ -145,17 +125,33 @@ const buildProviderScheduleSummaryFor = (
|
||||
? buildProviderScheduleSummary(attributes, now)
|
||||
: undefined;
|
||||
|
||||
const getProviderLastScanAt = (
|
||||
provider: ProvidersApiResponse["data"][number],
|
||||
): string | null => {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(provider.attributes, "last_scan_at")
|
||||
) {
|
||||
return provider.attributes.last_scan_at ?? null;
|
||||
}
|
||||
|
||||
return provider.attributes.connection.last_checked_at ?? null;
|
||||
};
|
||||
|
||||
const enrichProviders = (
|
||||
providersResponse: ProvidersApiResponse | undefined,
|
||||
scanScheduledProviderIds: Set<string>,
|
||||
schedulesByProviderId: Record<string, ScheduleAttributes>,
|
||||
): ProvidersProviderRow[] => {
|
||||
const providerGroupLookup = createProviderGroupLookup(providersResponse);
|
||||
const now = new Date();
|
||||
|
||||
return (providersResponse?.data ?? []).map((provider) => {
|
||||
const providerScheduleAttributes = buildScheduleAttributesFromProvider(
|
||||
provider.attributes,
|
||||
);
|
||||
const scheduleAttributes =
|
||||
providerScheduleAttributes ?? schedulesByProviderId[provider.id];
|
||||
const scheduleSummary = buildProviderScheduleSummaryFor(
|
||||
schedulesByProviderId[provider.id],
|
||||
scheduleAttributes,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -167,11 +163,11 @@ const enrichProviders = (
|
||||
(providerGroup: { id: string }) =>
|
||||
providerGroupLookup.get(providerGroup.id) ?? "Unknown Group",
|
||||
) ?? [],
|
||||
// A fired scheduled scan OR a configured schedule that hasn't fired yet.
|
||||
hasSchedule:
|
||||
scanScheduledProviderIds.has(provider.id) ||
|
||||
scheduleSummary !== undefined,
|
||||
// Provider scan_* fields are authoritative when present; otherwise we
|
||||
// only fall back to the /schedules resource, never materialized scans.
|
||||
hasSchedule: scheduleSummary !== undefined,
|
||||
scheduleSummary,
|
||||
lastScanAt: getProviderLastScanAt(provider),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -506,7 +502,6 @@ export async function loadProvidersAccountsViewData({
|
||||
const [
|
||||
providersResponse,
|
||||
allProvidersResponse,
|
||||
scansResponse,
|
||||
schedulesResponse,
|
||||
organizationsResponse,
|
||||
organizationUnitsResponse,
|
||||
@@ -523,18 +518,8 @@ export async function loadProvidersAccountsViewData({
|
||||
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
||||
// TODO: Replace with a dedicated lightweight endpoint when available.
|
||||
resolveActionResult(getAllProviders()),
|
||||
// Fetch active scheduled scans to flag providers whose schedule has fired.
|
||||
resolveActionResult(
|
||||
getScans({
|
||||
pageSize: 500,
|
||||
filters: {
|
||||
"filter[trigger]": SCAN_TRIGGER.SCHEDULED,
|
||||
"filter[state__in]": "scheduled,available",
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Fetch configured schedules to also flag providers whose schedule has not
|
||||
// fired yet (best-effort: absent in OSS, where the helper yields no ids).
|
||||
// Fetch configured schedules as a fallback when provider scan_* fields are
|
||||
// absent (best-effort: typically empty in OSS).
|
||||
resolveActionResult(getSchedules()),
|
||||
isCloud
|
||||
? listOrganizationsSafe()
|
||||
@@ -544,18 +529,11 @@ export async function loadProvidersAccountsViewData({
|
||||
: Promise.resolve(emptyOrganizationUnitsResponse),
|
||||
]);
|
||||
|
||||
const scanScheduledProviderIds = buildScheduledProviderIds(
|
||||
scansResponse?.data ?? [],
|
||||
);
|
||||
const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse);
|
||||
|
||||
const orgs = organizationsResponse?.data ?? [];
|
||||
const ous = organizationUnitsResponse?.data ?? [];
|
||||
const providers = enrichProviders(
|
||||
providersResponse,
|
||||
scanScheduledProviderIds,
|
||||
schedulesByProviderId,
|
||||
);
|
||||
const providers = enrichProviders(providersResponse, schedulesByProviderId);
|
||||
|
||||
const rows = buildProvidersTableRows({
|
||||
isCloud,
|
||||
|
||||
@@ -5,13 +5,12 @@ import { formatLocalTimeWithZone } from "@/lib/date-utils";
|
||||
import type { ScanScheduleSummary } from "@/types/scans";
|
||||
|
||||
interface LinkToScansProps {
|
||||
hasSchedule: boolean;
|
||||
schedule?: ScanScheduleSummary;
|
||||
}
|
||||
|
||||
// Matches the scans table Schedule column: cadence on top, next-run local time
|
||||
// underneath. Falls back to a plain label when the cadence is unknown.
|
||||
export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
|
||||
// underneath. Falls back to None when no configured schedule is present.
|
||||
export const LinkToScans = ({ schedule }: LinkToScansProps) => {
|
||||
if (schedule) {
|
||||
return (
|
||||
<StackedCell
|
||||
@@ -21,9 +20,5 @@ export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
{hasSchedule ? "Daily" : "None"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-text-neutral-secondary text-sm">None</span>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
type ProvidersProviderRow,
|
||||
type ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
|
||||
import { getColumnProviders } from "./column-providers";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/checkbox/checkbox", () => ({
|
||||
Checkbox: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({ value }: { value: string }) => <code>{value}</code>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
|
||||
<time>{dateTime}</time>
|
||||
),
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
}) => <span>{entityAlias ?? entityId}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/data-table-expand-all-toggle", () => ({
|
||||
DataTableExpandAllToggle: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/data-table-expandable-cell", () => ({
|
||||
DataTableExpandableCell: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../link-to-scans", () => ({
|
||||
LinkToScans: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./data-table-row-actions", () => ({
|
||||
DataTableRowActions: () => null,
|
||||
}));
|
||||
|
||||
const providerRow: ProvidersProviderRow = {
|
||||
id: "provider-1",
|
||||
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { id: "secret-1", type: "secrets" } },
|
||||
provider_groups: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
groupNames: [],
|
||||
hasSchedule: false,
|
||||
};
|
||||
|
||||
function renderLastScanCell(row: ProvidersTableRow) {
|
||||
const lastScanColumn = getColumnProviders(
|
||||
{},
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
).find((column) => column.id === "lastScan");
|
||||
|
||||
const cell = lastScanColumn?.cell;
|
||||
if (typeof cell !== "function") {
|
||||
throw new Error("Last Scan column cell renderer not found");
|
||||
}
|
||||
|
||||
const element = cell({
|
||||
row: { original: row },
|
||||
} as unknown as Parameters<typeof cell>[0]);
|
||||
|
||||
render(<>{element as ReactNode}</>);
|
||||
}
|
||||
|
||||
describe("getColumnProviders", () => {
|
||||
it("falls back to connection last_checked_at when lastScanAt is undefined", () => {
|
||||
renderLastScanCell({ ...providerRow, lastScanAt: undefined });
|
||||
|
||||
expect(screen.getByText("2026-01-01T00:00:00Z")).toBeVisible();
|
||||
expect(screen.queryByText("Never")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a null lastScanAt as authoritative", () => {
|
||||
renderLastScanCell({ ...providerRow, lastScanAt: null });
|
||||
|
||||
expect(screen.getByText("Never")).toBeVisible();
|
||||
expect(screen.queryByText("2026-01-01T00:00:00Z")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -227,22 +227,25 @@ export function getColumnProviders(
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const lastCheckedAt = (row.original as ProvidersProviderRow).attributes
|
||||
.connection.last_checked_at;
|
||||
const provider = row.original as ProvidersProviderRow;
|
||||
const lastScanAt =
|
||||
provider.lastScanAt !== undefined
|
||||
? provider.lastScanAt
|
||||
: provider.attributes.connection.last_checked_at;
|
||||
|
||||
if (!lastCheckedAt) {
|
||||
if (!lastScanAt) {
|
||||
return (
|
||||
<span className="text-text-neutral-tertiary text-sm">Never</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <DateWithTime dateTime={lastCheckedAt} showTime />;
|
||||
return <DateWithTime dateTime={lastScanAt} showTime />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "scanSchedule",
|
||||
size: 140,
|
||||
size: 180,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Scan Schedule" />
|
||||
),
|
||||
@@ -255,12 +258,7 @@ export function getColumnProviders(
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkToScans
|
||||
hasSchedule={row.original.hasSchedule}
|
||||
schedule={row.original.scheduleSummary}
|
||||
/>
|
||||
);
|
||||
return <LinkToScans schedule={row.original.scheduleSummary} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("LaunchStep", () => {
|
||||
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
it("defaults to run now and locks schedule mode outside Cloud", async () => {
|
||||
it("defaults to daily schedule mode and locks advanced cadence outside Cloud", async () => {
|
||||
// Given
|
||||
const onFooterChange = vi.fn();
|
||||
seedConnectedProvider();
|
||||
@@ -94,19 +94,20 @@ describe("LaunchStep", () => {
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "On a schedule" }),
|
||||
).toBeDisabled();
|
||||
).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "Run now" })).not.toBeChecked();
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /repeats/i }),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("radio", { name: "On a schedule" }),
|
||||
).toBeEnabled();
|
||||
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
|
||||
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
|
||||
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
|
||||
});
|
||||
|
||||
it("launches only an on-demand scan and never creates a legacy daily schedule", async () => {
|
||||
it("saves a legacy daily schedule by default", async () => {
|
||||
// Given
|
||||
const onClose = vi.fn();
|
||||
const onFooterChange = vi.fn();
|
||||
@@ -127,9 +128,43 @@ describe("LaunchStep", () => {
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
|
||||
const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData;
|
||||
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
|
||||
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
|
||||
expect(sentFormData.get("providerId")).toBe("provider-1");
|
||||
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("launches only an on-demand scan when run now is selected", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const onFooterChange = vi.fn();
|
||||
seedConnectedProvider();
|
||||
|
||||
render(
|
||||
<LaunchStep
|
||||
onBack={vi.fn()}
|
||||
onClose={onClose}
|
||||
onFooterChange={onFooterChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("radio", { name: "Run now" }));
|
||||
await waitFor(() =>
|
||||
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
|
||||
"Launch scan",
|
||||
),
|
||||
);
|
||||
await act(async () => {
|
||||
lastFooterConfig(onFooterChange)?.onAction?.();
|
||||
});
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
|
||||
expect(scheduleDailyMock).not.toHaveBeenCalled();
|
||||
expect(updateScheduleMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -93,17 +93,19 @@ export function LaunchStep({
|
||||
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
|
||||
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
|
||||
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
|
||||
const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
|
||||
const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED;
|
||||
const canUseScheduleMode = isAdvanced || isDailyLegacy;
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [mode, setMode] = useState<LaunchMode>(
|
||||
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
||||
canUseScheduleMode ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
|
||||
);
|
||||
const form = useForm<ScheduleFormValues>({
|
||||
resolver: zodResolver(scheduleFormSchema),
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
|
||||
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
|
||||
const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE;
|
||||
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
|
||||
const isActionBlocked =
|
||||
isLaunching ||
|
||||
@@ -129,10 +131,10 @@ export function LaunchStep({
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdvanced && mode !== LAUNCH_MODE.NOW) {
|
||||
if (!canUseScheduleMode && mode !== LAUNCH_MODE.NOW) {
|
||||
setMode(LAUNCH_MODE.NOW);
|
||||
}
|
||||
}, [isAdvanced, mode]);
|
||||
}, [canUseScheduleMode, mode]);
|
||||
|
||||
const launchOnDemandScan = async (): Promise<ActionErrorResult | null> => {
|
||||
if (!providerId || isBlocked) return null;
|
||||
@@ -327,10 +329,10 @@ export function LaunchStep({
|
||||
<RadioGroupItem
|
||||
value={LAUNCH_MODE.SCHEDULE}
|
||||
aria-label="On a schedule"
|
||||
disabled={!isAdvanced}
|
||||
disabled={!canUseScheduleMode}
|
||||
/>
|
||||
On a schedule
|
||||
{!isAdvanced &&
|
||||
{!canUseScheduleMode &&
|
||||
!isBlocked &&
|
||||
(isManualOnly ? (
|
||||
<CloudFeatureBadge label="Requires subscription" size="sm" />
|
||||
@@ -341,7 +343,7 @@ export function LaunchStep({
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{!isAdvanced && !isBlocked && (
|
||||
{isManualOnly && !isBlocked && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Scheduled scans are not available for this account. Run now to get
|
||||
immediate findings.
|
||||
@@ -361,6 +363,8 @@ export function LaunchStep({
|
||||
disabled={isLaunching || !providerId}
|
||||
showLaunchInitialScan
|
||||
showNextScheduledCopy
|
||||
canUseAdvancedSchedule={isAdvanced}
|
||||
showCloudUpgradeBadge={isDailyLegacy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getScheduleMock,
|
||||
@@ -91,12 +91,14 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
id,
|
||||
placeholder = "All Providers",
|
||||
}: {
|
||||
disabledValues?: string[];
|
||||
providers: { id: string; attributes: { alias: string; uid: string } }[];
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<input aria-label="Search Providers" placeholder="Search Providers..." />
|
||||
@@ -108,7 +110,7 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
onBatchChange("provider_id__in", [event.target.value])
|
||||
}
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
<option value="">{placeholder}</option>
|
||||
{providers.map((provider) => (
|
||||
<option
|
||||
key={provider.id}
|
||||
@@ -191,6 +193,10 @@ describe("LaunchScanModal", () => {
|
||||
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows a searchable provider selector", () => {
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
@@ -199,6 +205,16 @@ describe("LaunchScanModal", () => {
|
||||
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses a single-provider placeholder in the launch selector", () => {
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Select a Provider" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables disconnected providers in the launch selector", () => {
|
||||
render(
|
||||
<LaunchScanModal
|
||||
@@ -438,6 +454,64 @@ describe("LaunchScanModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the upcoming local hour when provider scan fields say there is no schedule", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.setSystemTime(new Date(2026, 5, 10, 11, 59, 0, 0));
|
||||
getScheduleMock.mockResolvedValue({
|
||||
data: {
|
||||
type: "schedules",
|
||||
id: provider.id,
|
||||
attributes: {
|
||||
scan_enabled: true,
|
||||
scan_frequency: "DAILY",
|
||||
scan_hour: 0,
|
||||
scan_timezone: "UTC",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const providerWithoutSchedule = {
|
||||
...provider,
|
||||
attributes: {
|
||||
...provider.attributes,
|
||||
scan_enabled: true,
|
||||
scan_frequency: "DAILY" as const,
|
||||
scan_hour: null,
|
||||
scan_timezone: "UTC",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: null,
|
||||
next_scan_at: null,
|
||||
last_scan_at: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[providerWithoutSchedule]}
|
||||
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("combobox", { name: "Scan Time" }),
|
||||
).toHaveTextContent("12:00pm");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Scan Time" }),
|
||||
).not.toHaveTextContent("12:00am");
|
||||
expect(getScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("launches the initial scan when the checkbox is checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvanced();
|
||||
@@ -460,6 +534,18 @@ describe("LaunchScanModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("disables Save Schedule until a provider is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdvanced();
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /save schedule/i }),
|
||||
).toBeDisabled();
|
||||
expect(getScheduleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks schedule mode outside ADVANCED (OSS default)", () => {
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { FormButtons } from "@/components/ui/form";
|
||||
import { toast, ToastAction } from "@/components/ui/toast";
|
||||
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
|
||||
import {
|
||||
buildScheduleAttributesFromProvider,
|
||||
getScanScheduleCapability,
|
||||
getScheduleFormDefaults,
|
||||
getScheduleFormValues,
|
||||
@@ -123,6 +124,14 @@ function LaunchScanForm({
|
||||
.filter((provider) => provider.attributes.connection.connected !== true)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
const getProviderScheduleAttributes = (id: string) => {
|
||||
const selectedProvider = providers.find((provider) => provider.id === id);
|
||||
|
||||
return selectedProvider
|
||||
? buildScheduleAttributesFromProvider(selectedProvider.attributes)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const loadSchedule = async (id: string) => {
|
||||
requestedProviderRef.current = id;
|
||||
if (!id) {
|
||||
@@ -130,6 +139,13 @@ function LaunchScanForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const providerScheduleAttributes = getProviderScheduleAttributes(id);
|
||||
if (providerScheduleAttributes) {
|
||||
scheduleForm.reset(getScheduleFormValues(providerScheduleAttributes));
|
||||
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED);
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING);
|
||||
const response = (await getSchedule(id)) as
|
||||
| ScheduleApiResponse
|
||||
@@ -279,6 +295,9 @@ function LaunchScanForm({
|
||||
}
|
||||
selectedValues={providerId ? [providerId] : []}
|
||||
closeOnSelect
|
||||
placeholder="Select a Provider"
|
||||
emptySelectionLabel="No provider selected"
|
||||
clearSelectionLabel="Clear provider selection"
|
||||
/>
|
||||
{providerError && <FieldError>{providerError}</FieldError>}
|
||||
</Field>
|
||||
@@ -365,7 +384,11 @@ function LaunchScanForm({
|
||||
}
|
||||
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
|
||||
isDisabled={
|
||||
isSubmitting || !providers.length || isScheduleLoading || isBlocked
|
||||
isSubmitting ||
|
||||
!providers.length ||
|
||||
isScheduleLoading ||
|
||||
isBlocked ||
|
||||
(isScheduleMode && !providerId)
|
||||
}
|
||||
rightIcon={<Rocket className="size-4" />}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,13 @@ beforeAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function ScheduleFieldsHarness() {
|
||||
function ScheduleFieldsHarness({
|
||||
canUseAdvancedSchedule = true,
|
||||
showCloudUpgradeBadge = false,
|
||||
}: {
|
||||
canUseAdvancedSchedule?: boolean;
|
||||
showCloudUpgradeBadge?: boolean;
|
||||
} = {}) {
|
||||
const form = useForm<ScheduleFormValues>({
|
||||
defaultValues: getScheduleFormDefaults(),
|
||||
});
|
||||
@@ -36,7 +42,8 @@ function ScheduleFieldsHarness() {
|
||||
<ScanScheduleFields
|
||||
form={form}
|
||||
showNextScheduledCopy
|
||||
canUseAdvancedSchedule
|
||||
canUseAdvancedSchedule={canUseAdvancedSchedule}
|
||||
showCloudUpgradeBadge={showCloudUpgradeBadge}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -73,4 +80,42 @@ describe("ScanScheduleFields", () => {
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses ordinal copy for monthly schedules", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<ScheduleFieldsHarness />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: /repeats/i }));
|
||||
await user.click(screen.getByRole("option", { name: /monthly/i }));
|
||||
|
||||
// Then
|
||||
expect(getHelperCopy(/Monthly on the 1st/)).toBeInTheDocument();
|
||||
expect(getHelperCopy(/Monthly on the 1st/)).not.toHaveTextContent(
|
||||
/Monthly on day/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => {
|
||||
// Given
|
||||
render(
|
||||
<ScheduleFieldsHarness
|
||||
canUseAdvancedSchedule={false}
|
||||
showCloudUpgradeBadge
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1);
|
||||
expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent(
|
||||
"Available in Prowler Cloud",
|
||||
);
|
||||
expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent(
|
||||
"Available in Prowler Cloud",
|
||||
);
|
||||
expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent(
|
||||
"Available in Prowler Cloud",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/shadcn";
|
||||
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
|
||||
import {
|
||||
formatDayOfMonth,
|
||||
formatScheduleHour,
|
||||
getBrowserTimezone,
|
||||
getNextScheduledRun,
|
||||
@@ -59,20 +60,18 @@ interface ScanScheduleFieldsProps {
|
||||
* (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts.
|
||||
*/
|
||||
canUseAdvancedSchedule?: boolean;
|
||||
/** Render the "Available in Prowler Cloud" upsell badge on locked controls. */
|
||||
/** Render the "Available in Prowler Cloud" upsell badge in the header. */
|
||||
showCloudUpgradeBadge?: boolean;
|
||||
}
|
||||
|
||||
function NumberSelect({
|
||||
label,
|
||||
labelAddon,
|
||||
value,
|
||||
values,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
labelAddon?: ReactNode;
|
||||
value: number;
|
||||
values: ReadonlyArray<{ value: number; label: string }>;
|
||||
onChange: (value: number) => void;
|
||||
@@ -80,10 +79,7 @@ function NumberSelect({
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{labelAddon}
|
||||
</div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(nextValue) => onChange(Number(nextValue))}
|
||||
@@ -119,7 +115,7 @@ function getScheduleSummary({
|
||||
case SCHEDULE_FREQUENCY.WEEKLY:
|
||||
return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`;
|
||||
case SCHEDULE_FREQUENCY.MONTHLY:
|
||||
return `Monthly on day ${dayOfMonth}`;
|
||||
return `Monthly on the ${formatDayOfMonth(dayOfMonth)}`;
|
||||
default:
|
||||
return "Daily";
|
||||
}
|
||||
@@ -164,6 +160,7 @@ export function ScanScheduleFields({
|
||||
<h3 className="text-text-neutral-primary text-sm font-medium">
|
||||
Scan Schedule
|
||||
</h3>
|
||||
{cloudUpgradeBadge}
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
@@ -175,7 +172,6 @@ export function ScanScheduleFields({
|
||||
render={({ field }) => (
|
||||
<NumberSelect
|
||||
label="Scan Time"
|
||||
labelAddon={cloudUpgradeBadge}
|
||||
value={field.value}
|
||||
values={HOUR_OPTIONS}
|
||||
onChange={field.onChange}
|
||||
@@ -189,10 +185,7 @@ export function ScanScheduleFields({
|
||||
name="frequency"
|
||||
render={({ field }) => (
|
||||
<Field>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Repeats</FieldLabel>
|
||||
{cloudUpgradeBadge}
|
||||
</div>
|
||||
<FieldLabel>Repeats</FieldLabel>
|
||||
<Select
|
||||
value={
|
||||
canUseAdvancedSchedule
|
||||
|
||||
@@ -139,7 +139,10 @@ describe("DataTable", () => {
|
||||
// Then
|
||||
expect(nameHeader).not.toHaveClass("sticky");
|
||||
expect(nameCell).not.toHaveClass("sticky");
|
||||
expect(nameHeader).toHaveClass("pr-6");
|
||||
expect(nameCell).toHaveClass("pr-6");
|
||||
expect(actionsHeaderElement).not.toHaveClass("sticky");
|
||||
expect(actionsHeaderElement).not.toHaveClass("pr-6");
|
||||
expect(actionsHeaderElement).not.toHaveClass("right-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("z-20");
|
||||
expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary");
|
||||
@@ -159,6 +162,7 @@ describe("DataTable", () => {
|
||||
expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full");
|
||||
expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument();
|
||||
expect(actionsCell).toHaveClass("sticky");
|
||||
expect(actionsCell).not.toHaveClass("pr-6");
|
||||
expect(actionsCell).toHaveClass("right-0");
|
||||
expect(actionsCell).toHaveClass("z-20");
|
||||
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
|
||||
|
||||
@@ -45,16 +45,20 @@ type DataTableRowAttributes = {
|
||||
*/
|
||||
const DEFAULT_COLUMN_SIZE = 150;
|
||||
const ACTIONS_COLUMN_ID = "actions";
|
||||
const TABLE_COLUMN_GAP_CLASS = "pr-6";
|
||||
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
|
||||
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
|
||||
|
||||
const getStickyActionColumnClassName = (
|
||||
const getTableColumnClassName = (
|
||||
columnId: string,
|
||||
variant: "header" | "cell",
|
||||
) => {
|
||||
if (columnId !== ACTIONS_COLUMN_ID) return undefined;
|
||||
const isActionsColumn = columnId === ACTIONS_COLUMN_ID;
|
||||
|
||||
return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS;
|
||||
return cn(
|
||||
!isActionsColumn && TABLE_COLUMN_GAP_CLASS,
|
||||
isActionsColumn && variant === "cell" && STICKY_ACTION_CELL_CLASS,
|
||||
);
|
||||
};
|
||||
|
||||
interface DataTableProviderProps<TData, TValue> {
|
||||
@@ -302,7 +306,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getStickyActionColumnClassName(
|
||||
className={getTableColumnClassName(
|
||||
header.column.id,
|
||||
"header",
|
||||
)}
|
||||
@@ -348,7 +352,7 @@ export function DataTable<TData, TValue>({
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getStickyActionColumnClassName(
|
||||
className={getTableColumnClassName(
|
||||
cell.column.id,
|
||||
"cell",
|
||||
)}
|
||||
|
||||
+92
-12
@@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildProviderScheduleSummary,
|
||||
buildScheduleAttributesFromProvider,
|
||||
buildSchedulesByProviderId,
|
||||
buildScheduleUpdatePayload,
|
||||
formatDayOfMonth,
|
||||
formatScheduleHour,
|
||||
getBrowserTimezone,
|
||||
getNextScheduledRun,
|
||||
getScanScheduleCapability,
|
||||
getScheduleFormDefaults,
|
||||
getScheduleFormValues,
|
||||
isScheduleConfigured,
|
||||
} from "@/lib/schedules";
|
||||
@@ -161,6 +164,25 @@ describe("formatScheduleHour", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayOfMonth", () => {
|
||||
it.each([
|
||||
[1, "1st"],
|
||||
[2, "2nd"],
|
||||
[3, "3rd"],
|
||||
[4, "4th"],
|
||||
[11, "11th"],
|
||||
[12, "12th"],
|
||||
[13, "13th"],
|
||||
[21, "21st"],
|
||||
[22, "22nd"],
|
||||
[23, "23rd"],
|
||||
[24, "24th"],
|
||||
[31, "31st"],
|
||||
])("formats day %i as %s", (day, expected) => {
|
||||
expect(formatDayOfMonth(day)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isScheduleConfigured", () => {
|
||||
it("treats a null scan_hour as not configured", () => {
|
||||
expect(isScheduleConfigured({ scan_hour: null })).toBe(false);
|
||||
@@ -175,6 +197,26 @@ describe("isScheduleConfigured", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScheduleFormDefaults", () => {
|
||||
it("uses the current local hour when the clock is exactly on the hour", () => {
|
||||
expect(
|
||||
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 0, 0, 0)).hour,
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it("uses the next local hour when the current hour already started", () => {
|
||||
expect(
|
||||
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 30, 0, 0)).hour,
|
||||
).toBe(11);
|
||||
});
|
||||
|
||||
it("wraps the upcoming hour from 23:xx to 0", () => {
|
||||
expect(
|
||||
getScheduleFormDefaults(new Date(2026, 5, 10, 23, 1, 0, 0)).hour,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScheduleFormValues", () => {
|
||||
const buildAttributes = (
|
||||
overrides: Partial<ScheduleAttributes> = {},
|
||||
@@ -190,7 +232,9 @@ describe("getScheduleFormValues", () => {
|
||||
});
|
||||
|
||||
it("returns defaults when there is no schedule", () => {
|
||||
expect(getScheduleFormValues(null)).toEqual({
|
||||
expect(
|
||||
getScheduleFormValues(null, new Date(2026, 5, 10, 0, 0, 0, 0)),
|
||||
).toEqual({
|
||||
frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
hour: 0,
|
||||
dayOfWeek: 1,
|
||||
@@ -201,16 +245,19 @@ describe("getScheduleFormValues", () => {
|
||||
});
|
||||
|
||||
it("returns defaults when scan_hour is null (unconfigured provider)", () => {
|
||||
expect(getScheduleFormValues(buildAttributes({ scan_hour: null }))).toEqual(
|
||||
{
|
||||
frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
hour: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
intervalHours: 48,
|
||||
launchInitialScan: false,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
getScheduleFormValues(
|
||||
buildAttributes({ scan_hour: null }),
|
||||
new Date(2026, 5, 10, 0, 0, 0, 0),
|
||||
),
|
||||
).toEqual({
|
||||
frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
hour: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
intervalHours: 48,
|
||||
launchInitialScan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a configured schedule onto the form", () => {
|
||||
@@ -439,6 +486,39 @@ describe("buildSchedulesByProviderId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleAttributesFromProvider", () => {
|
||||
it("returns undefined when the provider payload does not include scan fields", () => {
|
||||
expect(buildScheduleAttributesFromProvider({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps scan_hour null as an unconfigured provider schedule", () => {
|
||||
const attributes = buildScheduleAttributesFromProvider({
|
||||
scan_enabled: true,
|
||||
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
scan_hour: null,
|
||||
scan_timezone: "UTC",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: null,
|
||||
next_scan_at: null,
|
||||
last_scan_at: null,
|
||||
});
|
||||
|
||||
expect(attributes).toEqual({
|
||||
scan_enabled: true,
|
||||
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
scan_hour: null,
|
||||
scan_timezone: "UTC",
|
||||
scan_interval_hours: null,
|
||||
scan_day_of_week: null,
|
||||
scan_day_of_month: null,
|
||||
next_scan_at: null,
|
||||
last_scan_at: null,
|
||||
});
|
||||
expect(isScheduleConfigured(attributes!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderScheduleSummary", () => {
|
||||
const buildAttributes = (
|
||||
overrides: Partial<ScheduleAttributes> = {},
|
||||
@@ -463,7 +543,7 @@ describe("buildProviderScheduleSummary", () => {
|
||||
],
|
||||
[
|
||||
{ scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 },
|
||||
"Monthly on day 15",
|
||||
"Monthly on the 15th",
|
||||
],
|
||||
[
|
||||
{ scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 },
|
||||
|
||||
+61
-5
@@ -12,13 +12,21 @@ import {
|
||||
type ScheduleUpdatePayload,
|
||||
} from "@/types/schedules";
|
||||
|
||||
const DEFAULT_SCHEDULE_HOUR = 0;
|
||||
const DEFAULT_DAY_OF_WEEK = 1;
|
||||
const DEFAULT_DAY_OF_MONTH = 1;
|
||||
// The backend (prowler-cloud) enforces SCAN_INTERVAL_HOURS_MIN = 24. 48 is well
|
||||
// above that floor.
|
||||
const SCAN_INTERVAL_HOURS_MIN = 24;
|
||||
const DEFAULT_INTERVAL_HOURS = 48;
|
||||
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
|
||||
const DAY_OF_MONTH_SUFFIXES: Record<Intl.LDMLPluralRule, string> = {
|
||||
zero: "th",
|
||||
one: "st",
|
||||
two: "nd",
|
||||
few: "rd",
|
||||
many: "th",
|
||||
other: "th",
|
||||
};
|
||||
|
||||
export const scheduleFormSchema = z.object({
|
||||
frequency: z.enum(SCHEDULE_FREQUENCY),
|
||||
@@ -65,6 +73,10 @@ export function formatScheduleHour(hour: number): string {
|
||||
return `${displayHour}:00${period}`;
|
||||
}
|
||||
|
||||
export function formatDayOfMonth(day: number): string {
|
||||
return `${day}${DAY_OF_MONTH_SUFFIXES[ordinalRules.select(day)]}`;
|
||||
}
|
||||
|
||||
export function getBrowserTimezone(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "UTC";
|
||||
@@ -73,10 +85,19 @@ export function getBrowserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
}
|
||||
|
||||
export function getScheduleFormDefaults(): ScheduleFormValues {
|
||||
function getDefaultScheduleHour(now: Date): number {
|
||||
const isOnTheHour =
|
||||
now.getMinutes() === 0 &&
|
||||
now.getSeconds() === 0 &&
|
||||
now.getMilliseconds() === 0;
|
||||
|
||||
return (now.getHours() + (isOnTheHour ? 0 : 1)) % 24;
|
||||
}
|
||||
|
||||
export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues {
|
||||
return {
|
||||
frequency: SCHEDULE_FREQUENCY.DAILY,
|
||||
hour: DEFAULT_SCHEDULE_HOUR,
|
||||
hour: getDefaultScheduleHour(now),
|
||||
dayOfWeek: DEFAULT_DAY_OF_WEEK,
|
||||
dayOfMonth: DEFAULT_DAY_OF_MONTH,
|
||||
intervalHours: DEFAULT_INTERVAL_HOURS,
|
||||
@@ -86,8 +107,9 @@ export function getScheduleFormDefaults(): ScheduleFormValues {
|
||||
|
||||
export function getScheduleFormValues(
|
||||
schedule?: ScheduleAttributes | null,
|
||||
now = new Date(),
|
||||
): ScheduleFormValues {
|
||||
const defaults = getScheduleFormDefaults();
|
||||
const defaults = getScheduleFormDefaults(now);
|
||||
|
||||
if (!schedule || schedule.scan_hour === null) {
|
||||
return defaults;
|
||||
@@ -148,6 +170,38 @@ export function buildSchedulesByProviderId(
|
||||
return byProviderId;
|
||||
}
|
||||
|
||||
interface ProviderScheduleAttributeSource {
|
||||
scan_enabled?: boolean | null;
|
||||
scan_frequency?: ScheduleAttributes["scan_frequency"] | null;
|
||||
scan_hour?: number | null;
|
||||
scan_timezone?: string | null;
|
||||
scan_interval_hours?: number | null;
|
||||
scan_day_of_week?: number | null;
|
||||
scan_day_of_month?: number | null;
|
||||
next_scan_at?: string | null;
|
||||
last_scan_at?: string | null;
|
||||
}
|
||||
|
||||
export function buildScheduleAttributesFromProvider(
|
||||
attributes: ProviderScheduleAttributeSource,
|
||||
): ScheduleAttributes | undefined {
|
||||
if (!Object.prototype.hasOwnProperty.call(attributes, "scan_hour")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
scan_enabled: attributes.scan_enabled ?? true,
|
||||
scan_frequency: attributes.scan_frequency ?? SCHEDULE_FREQUENCY.DAILY,
|
||||
scan_hour: attributes.scan_hour ?? null,
|
||||
scan_timezone: attributes.scan_timezone ?? "UTC",
|
||||
scan_interval_hours: attributes.scan_interval_hours ?? null,
|
||||
scan_day_of_week: attributes.scan_day_of_week ?? null,
|
||||
scan_day_of_month: attributes.scan_day_of_month ?? null,
|
||||
next_scan_at: attributes.next_scan_at,
|
||||
last_scan_at: attributes.last_scan_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider has an explicitly configured scan schedule.
|
||||
*
|
||||
@@ -224,7 +278,9 @@ export function getScheduleCadenceParts(
|
||||
}
|
||||
case SCHEDULE_FREQUENCY.MONTHLY:
|
||||
return {
|
||||
cadence: `Monthly on day ${attributes.scan_day_of_month ?? 1}`,
|
||||
cadence: `Monthly on the ${formatDayOfMonth(
|
||||
attributes.scan_day_of_month ?? 1,
|
||||
)}`,
|
||||
time,
|
||||
};
|
||||
case SCHEDULE_FREQUENCY.INTERVAL:
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ProvidersProviderRow
|
||||
hasSchedule: boolean;
|
||||
/** Cadence/next-run summary when the provider has a configured schedule. */
|
||||
scheduleSummary?: ScanScheduleSummary;
|
||||
/** Completed-at timestamp for the provider's last scan when exposed by API. */
|
||||
lastScanAt?: string | null;
|
||||
subRows?: ProvidersTableRow[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ScheduleFrequency } from "./schedules";
|
||||
|
||||
export const PROVIDER_TYPES = [
|
||||
"aws",
|
||||
"azure",
|
||||
@@ -65,6 +67,15 @@ export interface ProviderProps {
|
||||
};
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
scan_frequency?: ScheduleFrequency | null;
|
||||
scan_hour?: number | null;
|
||||
scan_day_of_week?: number | null;
|
||||
scan_day_of_month?: number | null;
|
||||
scan_interval_hours?: number | null;
|
||||
scan_timezone?: string | null;
|
||||
scan_enabled?: boolean | null;
|
||||
next_scan_at?: string | null;
|
||||
last_scan_at?: string | null;
|
||||
created_by: {
|
||||
object: string;
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user