Skip to content

Commit 70bc766

Browse files
fix(ui): improve scan scheduling flows
1 parent dc228e8 commit 70bc766

18 files changed

Lines changed: 445 additions & 54 deletions

ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,24 @@ describe("AccountsSelector", () => {
147147
placeholder: "Search Providers...",
148148
emptyMessage: "No Providers found.",
149149
});
150+
expect(screen.getByText("All Providers")).toBeInTheDocument();
150151
expect(screen.getByText("Production AWS")).toBeInTheDocument();
151152
});
152153

154+
it("supports contextual placeholder and empty-selection copy", () => {
155+
render(
156+
<AccountsSelector
157+
providers={providers}
158+
placeholder="Select a Provider"
159+
emptySelectionLabel="No provider selected"
160+
clearSelectionLabel="Clear provider selection"
161+
/>,
162+
);
163+
164+
expect(screen.getByText("Select a Provider")).toBeInTheDocument();
165+
expect(screen.getByText("No provider selected")).toBeInTheDocument();
166+
});
167+
153168
it("allows disabling search explicitly", () => {
154169
render(<AccountsSelector providers={providers} search={false} />);
155170

ui/app/(prowler)/_overview/_components/accounts-selector.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ interface AccountsSelectorBaseProps {
3232
id?: string;
3333
disabledValues?: string[];
3434
closeOnSelect?: boolean;
35+
placeholder?: string;
36+
emptySelectionLabel?: string;
37+
clearSelectionLabel?: string;
3538
}
3639

3740
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
@@ -73,6 +76,9 @@ export function AccountsSelector({
7376
emptyMessage: "No Providers found.",
7477
},
7578
closeOnSelect = false,
79+
placeholder = "All Providers",
80+
emptySelectionLabel = "All selected",
81+
clearSelectionLabel = "Select All",
7682
}: AccountsSelectorProps) {
7783
const searchParams = useSearchParams();
7884
const { navigateWithParams } = useUrlFilters();
@@ -163,7 +169,7 @@ export function AccountsSelector({
163169
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
164170
>
165171
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
166-
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
172+
{selectedLabel() || <MultiSelectValue placeholder={placeholder} />}
167173
</MultiSelectTrigger>
168174
<MultiSelectContent search={search}>
169175
{visibleProviders.length > 0 ? (
@@ -187,7 +193,9 @@ export function AccountsSelector({
187193
}
188194
}}
189195
>
190-
{selectedIds.length === 0 ? "All selected" : "Select All"}
196+
{selectedIds.length === 0
197+
? emptySelectionLabel
198+
: clearSelectionLabel}
191199
</div>
192200
{visibleProviders.map((p) => {
193201
const value = getProviderValue(p);

ui/app/(prowler)/providers/providers-page.utils.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,103 @@ describe("loadProvidersAccountsViewData", () => {
886886
).toBeUndefined();
887887
});
888888

889+
it("uses provider schedule attributes as authoritative when scan_hour is null", async () => {
890+
// Given — provider-1 still has a materialized scheduled scan row, but the
891+
// provider payload says the schedule was removed.
892+
providersActionsMock.getProviders.mockResolvedValue({
893+
...providersResponse,
894+
data: [
895+
{
896+
...providersResponse.data[0],
897+
attributes: {
898+
...providersResponse.data[0].attributes,
899+
scan_enabled: true,
900+
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
901+
scan_hour: null,
902+
scan_timezone: "UTC",
903+
scan_interval_hours: null,
904+
scan_day_of_week: null,
905+
scan_day_of_month: null,
906+
next_scan_at: null,
907+
last_scan_at: null,
908+
},
909+
},
910+
providersResponse.data[1],
911+
],
912+
});
913+
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
914+
scansActionsMock.getScans.mockResolvedValue({
915+
data: [
916+
{
917+
type: "scans",
918+
id: "scan-1",
919+
attributes: { trigger: "scheduled", state: "scheduled" },
920+
relationships: {
921+
provider: { data: { type: "providers", id: "provider-1" } },
922+
},
923+
},
924+
],
925+
});
926+
schedulesActionsMock.getSchedules.mockResolvedValue({
927+
data: [buildSchedule("provider-1", { scan_hour: 9 })],
928+
});
929+
930+
// When
931+
const viewData = await loadProvidersAccountsViewData({
932+
searchParams: {} satisfies SearchParamsProps,
933+
isCloud: false,
934+
});
935+
936+
// Then
937+
const providerRow = findProviderRow(viewData.rows, "provider-1");
938+
expect(providerRow?.hasSchedule).toBe(false);
939+
expect(providerRow?.scheduleSummary).toBeUndefined();
940+
expect(providerRow?.lastScanAt).toBeNull();
941+
});
942+
943+
it("builds provider schedule and last scan values from the provider payload", async () => {
944+
// Given
945+
providersActionsMock.getProviders.mockResolvedValue({
946+
...providersResponse,
947+
data: [
948+
{
949+
...providersResponse.data[0],
950+
attributes: {
951+
...providersResponse.data[0].attributes,
952+
scan_enabled: true,
953+
scan_frequency: SCHEDULE_FREQUENCY.MONTHLY,
954+
scan_hour: 8,
955+
scan_timezone: "Europe/Madrid",
956+
scan_interval_hours: null,
957+
scan_day_of_week: null,
958+
scan_day_of_month: 24,
959+
next_scan_at: "2026-06-24T06:00:00Z",
960+
last_scan_at: "2026-06-23T06:00:00Z",
961+
},
962+
},
963+
providersResponse.data[1],
964+
],
965+
});
966+
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
967+
scansActionsMock.getScans.mockResolvedValue({ data: [] });
968+
schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" });
969+
970+
// When
971+
const viewData = await loadProvidersAccountsViewData({
972+
searchParams: {} satisfies SearchParamsProps,
973+
isCloud: false,
974+
});
975+
976+
// Then
977+
const providerRow = findProviderRow(viewData.rows, "provider-1");
978+
expect(providerRow?.hasSchedule).toBe(true);
979+
expect(providerRow?.scheduleSummary?.cadence).toBe("Monthly on the 24th");
980+
expect(providerRow?.scheduleSummary?.nextScanAt).toBe(
981+
"2026-06-24T06:00:00Z",
982+
);
983+
expect(providerRow?.lastScanAt).toBe("2026-06-23T06:00:00Z");
984+
});
985+
889986
it("ignores paused or unconfigured schedules", async () => {
890987
// Given — provider-1 paused (disabled), provider-2 never configured.
891988
providersActionsMock.getProviders.mockResolvedValue(providersResponse);

ui/app/(prowler)/providers/providers-page.utils.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@/lib/helper-filters";
1212
import {
1313
buildProviderScheduleSummary,
14+
buildScheduleAttributesFromProvider,
1415
buildSchedulesByProviderId,
1516
isScheduleConfigured,
1617
} from "@/lib/schedules";
@@ -145,6 +146,18 @@ const buildProviderScheduleSummaryFor = (
145146
? buildProviderScheduleSummary(attributes, now)
146147
: undefined;
147148

149+
const getProviderLastScanAt = (
150+
provider: ProvidersApiResponse["data"][number],
151+
): string | null => {
152+
if (
153+
Object.prototype.hasOwnProperty.call(provider.attributes, "last_scan_at")
154+
) {
155+
return provider.attributes.last_scan_at ?? null;
156+
}
157+
158+
return provider.attributes.connection.last_checked_at ?? null;
159+
};
160+
148161
const enrichProviders = (
149162
providersResponse: ProvidersApiResponse | undefined,
150163
scanScheduledProviderIds: Set<string>,
@@ -154,10 +167,17 @@ const enrichProviders = (
154167
const now = new Date();
155168

156169
return (providersResponse?.data ?? []).map((provider) => {
170+
const providerScheduleAttributes = buildScheduleAttributesFromProvider(
171+
provider.attributes,
172+
);
173+
const scheduleAttributes =
174+
providerScheduleAttributes ?? schedulesByProviderId[provider.id];
157175
const scheduleSummary = buildProviderScheduleSummaryFor(
158-
schedulesByProviderId[provider.id],
176+
scheduleAttributes,
159177
now,
160178
);
179+
const hasProviderScheduleAttributes =
180+
providerScheduleAttributes !== undefined;
161181

162182
return {
163183
...provider,
@@ -167,11 +187,14 @@ const enrichProviders = (
167187
(providerGroup: { id: string }) =>
168188
providerGroupLookup.get(providerGroup.id) ?? "Unknown Group",
169189
) ?? [],
170-
// A fired scheduled scan OR a configured schedule that hasn't fired yet.
171-
hasSchedule:
172-
scanScheduledProviderIds.has(provider.id) ||
173-
scheduleSummary !== undefined,
190+
// Provider scan_* fields are authoritative when present. The scheduled
191+
// scan fallback only exists for older APIs that do not expose them.
192+
hasSchedule: hasProviderScheduleAttributes
193+
? scheduleSummary !== undefined
194+
: scanScheduledProviderIds.has(provider.id) ||
195+
scheduleSummary !== undefined,
174196
scheduleSummary,
197+
lastScanAt: getProviderLastScanAt(provider),
175198
};
176199
});
177200
};

ui/components/providers/link-to-scans.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
2323

2424
return (
2525
<span className="text-text-neutral-secondary text-sm">
26-
{hasSchedule ? "Daily" : "None"}
26+
{hasSchedule ? "Scheduled" : "None"}
2727
</span>
2828
);
2929
};

ui/components/providers/table/column-providers.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,22 +227,25 @@ export function getColumnProviders(
227227
return <span className="text-text-neutral-tertiary text-sm">-</span>;
228228
}
229229

230-
const lastCheckedAt = (row.original as ProvidersProviderRow).attributes
231-
.connection.last_checked_at;
230+
const provider = row.original as ProvidersProviderRow;
231+
const lastScanAt =
232+
"lastScanAt" in provider
233+
? provider.lastScanAt
234+
: provider.attributes.connection.last_checked_at;
232235

233-
if (!lastCheckedAt) {
236+
if (!lastScanAt) {
234237
return (
235238
<span className="text-text-neutral-tertiary text-sm">Never</span>
236239
);
237240
}
238241

239-
return <DateWithTime dateTime={lastCheckedAt} showTime />;
242+
return <DateWithTime dateTime={lastScanAt} showTime />;
240243
},
241244
enableSorting: false,
242245
},
243246
{
244247
id: "scanSchedule",
245-
size: 140,
248+
size: 180,
246249
header: ({ column }) => (
247250
<DataTableColumnHeader column={column} title="Scan Schedule" />
248251
),

ui/components/providers/wizard/steps/launch-step.test.tsx

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe("LaunchStep", () => {
7979
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
8080
});
8181

82-
it("defaults to run now and locks schedule mode outside Cloud", async () => {
82+
it("defaults to daily schedule mode and locks advanced cadence outside Cloud", async () => {
8383
// Given
8484
const onFooterChange = vi.fn();
8585
seedConnectedProvider();
@@ -94,19 +94,20 @@ describe("LaunchStep", () => {
9494

9595
// Then
9696
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
97-
expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
9897
expect(
9998
screen.getByRole("radio", { name: "On a schedule" }),
100-
).toBeDisabled();
99+
).toBeChecked();
100+
expect(screen.getByRole("radio", { name: "Run now" })).not.toBeChecked();
101101
expect(
102-
screen.queryByRole("combobox", { name: /repeats/i }),
103-
).not.toBeInTheDocument();
102+
screen.getByRole("radio", { name: "On a schedule" }),
103+
).toBeEnabled();
104+
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
104105

105106
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
106-
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
107+
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
107108
});
108109

109-
it("launches only an on-demand scan and never creates a legacy daily schedule", async () => {
110+
it("saves a legacy daily schedule by default", async () => {
110111
// Given
111112
const onClose = vi.fn();
112113
const onFooterChange = vi.fn();
@@ -127,9 +128,43 @@ describe("LaunchStep", () => {
127128
});
128129

129130
// Then
130-
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
131-
const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData;
131+
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
132+
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
132133
expect(sentFormData.get("providerId")).toBe("provider-1");
134+
expect(scanOnDemandMock).not.toHaveBeenCalled();
135+
expect(updateScheduleMock).not.toHaveBeenCalled();
136+
expect(onClose).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it("launches only an on-demand scan when run now is selected", async () => {
140+
// Given
141+
const user = userEvent.setup();
142+
const onClose = vi.fn();
143+
const onFooterChange = vi.fn();
144+
seedConnectedProvider();
145+
146+
render(
147+
<LaunchStep
148+
onBack={vi.fn()}
149+
onClose={onClose}
150+
onFooterChange={onFooterChange}
151+
/>,
152+
);
153+
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
154+
155+
// When
156+
await user.click(screen.getByRole("radio", { name: "Run now" }));
157+
await waitFor(() =>
158+
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
159+
"Launch scan",
160+
),
161+
);
162+
await act(async () => {
163+
lastFooterConfig(onFooterChange)?.onAction?.();
164+
});
165+
166+
// Then
167+
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
133168
expect(scheduleDailyMock).not.toHaveBeenCalled();
134169
expect(updateScheduleMock).not.toHaveBeenCalled();
135170
expect(onClose).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)