Files
Alejandro Bailo 0cabceb09c feat(ui): source scheduled scans tab from /schedules endpoint (#11670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:44:37 +02:00

468 lines
13 KiB
TypeScript

import {
PROVIDER_IN_FILTER_KEY,
PROVIDER_TYPE_IN_FILTER_KEY,
} from "@/lib/provider-filters";
import {
buildProviderScheduleSummary,
describeScheduleCadence,
getNextScheduledRunInTimezone,
getScheduleCadenceParts,
isScheduleConfigured,
} from "@/lib/schedules";
import {
DEFAULT_SCAN_JOBS_TAB,
type MetaDataProps,
type ProviderProps,
SCAN_JOBS_TAB,
SCAN_STATE,
SCAN_TRIGGER,
type ScanAttributes,
type ScanFindingsSummary,
type ScanJobsTab,
type ScanProps,
type ScanState,
type ScanTrigger,
type ScheduleAttributes,
type ScheduleProps,
type SearchParamsProps,
} from "@/types";
export const SCAN_STATE_FILTER_KEYS = [
"filter[state]",
"filter[state__in]",
] as const;
const ALL_VALUE = "all";
const SCAN_JOBS_TAB_STATES: Record<ScanJobsTab, ScanState[]> = {
[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],
};
const toStateFilter = (states: ScanState[]): Record<string, string> => ({
"filter[state__in]": states.join(","),
});
const SCAN_JOBS_TAB_FILTERS = Object.fromEntries(
Object.entries(SCAN_JOBS_TAB_STATES).map(([tab, states]) => [
tab,
toStateFilter(states),
]),
) as Record<ScanJobsTab, Record<string, string>>;
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 isSearchParamValue(value: unknown): value is string | string[] {
return typeof value === "string" || Array.isArray(value);
}
export function getScanJobsUserFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
const tab = getScanJobsTab(searchParams.tab);
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
(filters, [key, value]) => {
if (tab === SCAN_JOBS_TAB.SCHEDULED && key === "filter[trigger]") {
return filters;
}
if (
key.startsWith("filter[") &&
!isScanStateFilterKey(key) &&
isSearchParamValue(value)
) {
filters[key] = value;
}
return filters;
},
{},
);
}
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<string, string> {
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 {
if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED)
return "Scheduled Scan";
return scan.attributes.name?.trim() || "-";
}
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),
})),
];
}
export interface BuildPendingScheduleRowsParams {
providers: ProviderProps[];
schedulesByProviderId: Record<string, ScheduleAttributes>;
/** Providers that already have a real `state=scheduled` Scan row. */
coveredProviderIds: Set<string>;
now: Date;
}
interface AppendPendingScheduleRowsToPageParams
extends BuildPendingScheduleRowsParams {
scans: ScanProps[];
meta?: MetaDataProps;
page: number;
pageSize: number;
}
/**
* Synthesizes Scheduled-tab rows for configured schedules without a Scan row
* yet (the backend only creates one after each run).
*/
export function buildPendingScheduleRows({
providers,
schedulesByProviderId,
coveredProviderIds,
now,
}: BuildPendingScheduleRowsParams): ScanProps[] {
return providers.flatMap((provider) => {
if (coveredProviderIds.has(provider.id)) return [];
const schedule = schedulesByProviderId[provider.id];
if (!schedule || !isScheduleConfigured(schedule) || !schedule.scan_enabled)
return [];
// Prefer the server-computed next fire time; fall back to a client estimate.
const nextScanAt =
schedule.next_scan_at ??
getNextScheduledRunInTimezone(schedule, now)?.toISOString() ??
null;
return [
{
type: "scans" as const,
id: `pending-schedule-${provider.id}`,
attributes: {
name: "",
trigger: SCAN_TRIGGER.SCHEDULED,
state: SCAN_STATE.SCHEDULED,
unique_resource_count: 0,
progress: 0,
scanner_args: null,
duration: null,
started_at: null,
inserted_at: now.toISOString(),
completed_at: null,
scheduled_at: nextScanAt,
next_scan_at: null,
},
relationships: {
provider: { data: { type: "providers", id: provider.id } },
// No Celery task behind synthetic rows.
task: { data: { type: "tasks", id: "" } },
},
providerInfo: {
provider: provider.attributes.provider,
uid: provider.attributes.uid,
alias: provider.attributes.alias,
},
pendingSchedule: {
summary: describeScheduleCadence(schedule),
cadence: getScheduleCadenceParts(schedule).cadence,
nextScanAt,
lastScanAt: schedule.last_scan_at ?? null,
},
},
];
});
}
export function getProviderIdsFromScans(scans: ScanProps[]): Set<string> {
return new Set(
scans
.map((scan) => scan.relationships?.provider?.data?.id)
.filter((id): id is string => Boolean(id)),
);
}
export function appendPendingScheduleRowsToPage({
scans,
meta,
page,
pageSize,
providers,
schedulesByProviderId,
coveredProviderIds,
now,
}: AppendPendingScheduleRowsToPageParams): {
data: ScanProps[];
meta?: MetaDataProps;
} {
// The API paginates real scheduled scans, while pending rows come from
// configured schedules without a scan yet. Reconcile both sources here so the
// rendered rows and pagination metadata describe the same combined list.
const pendingRows = buildPendingScheduleRows({
providers,
schedulesByProviderId,
coveredProviderIds,
now,
});
const safePageSize = Math.max(1, pageSize);
const realCount = meta?.pagination.count ?? scans.length;
const pendingStart = Math.max(0, (page - 1) * safePageSize - realCount);
const pendingSlots = Math.max(0, safePageSize - scans.length);
const pagePendingRows = pendingRows.slice(
pendingStart,
pendingStart + pendingSlots,
);
const combinedCount = realCount + pendingRows.length;
const combinedPages =
combinedCount === 0 ? 0 : Math.ceil(combinedCount / safePageSize);
return {
data: [...scans, ...pagePendingRows],
meta: meta
? {
...meta,
pagination: {
...meta.pagination,
page,
count: combinedCount,
pages: combinedPages,
},
}
: undefined,
};
}
// Provider filters the `/schedules` endpoint accepts (by id / type). The scans
// filter-bar's uid filter is not supported there, so it is intentionally dropped.
const SCHEDULE_FORWARDED_FILTER_KEYS = [
PROVIDER_IN_FILTER_KEY,
PROVIDER_TYPE_IN_FILTER_KEY,
] as const;
/** Provider filters forwarded to `/schedules` so the backend applies them and pagination stays native. */
export function pickScheduleProviderFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
const filters: Record<string, string | string[]> = {};
for (const key of SCHEDULE_FORWARDED_FILTER_KEYS) {
const value = searchParams[key];
if (typeof value === "string" || Array.isArray(value)) {
filters[key] = value;
}
}
return filters;
}
interface SchedulesPageResult {
data?: ScheduleProps[] | null;
included?: { type: string; id: string }[];
meta?: MetaDataProps;
error?: unknown;
}
/** Builds Scheduled-tab rows + meta from a `getSchedulesPage` result (advanced/Cloud path). */
export function buildScheduledTabRows(
result: SchedulesPageResult | null | undefined,
now: Date,
): { data: ScanProps[]; meta?: MetaDataProps } {
if (!result || result.error) return { data: [] };
const providerById = new Map(
((result.included ?? []) as ProviderProps[])
.filter((resource) => resource.type === "providers")
.map((provider) => [provider.id, provider]),
);
const data = (result.data ?? []).map((schedule) =>
mapScheduleToScanRow(schedule, providerById.get(schedule.id), now),
);
return { data, meta: result.meta };
}
/** Maps a `/schedules` resource (1:1 with a provider) to the Scheduled-tab row shape. */
export function mapScheduleToScanRow(
schedule: ScheduleProps,
provider: ProviderProps | undefined,
now: Date,
): ScanProps {
// Reuse the canonical cadence + next/last-run summary used across the app.
const summary = buildProviderScheduleSummary(schedule.attributes, now);
return {
type: "scans",
id: `schedule-${schedule.id}`,
attributes: {
name: "",
trigger: SCAN_TRIGGER.SCHEDULED,
state: SCAN_STATE.SCHEDULED,
unique_resource_count: 0,
progress: 0,
scanner_args: null,
duration: null,
started_at: null,
inserted_at: now.toISOString(),
completed_at: null,
scheduled_at: summary.nextScanAt ?? null,
next_scan_at: null,
},
relationships: {
// The schedule resource id IS the provider id.
provider: { data: { type: "providers", id: schedule.id } },
// No Celery task behind a schedule row.
task: { data: { type: "tasks", id: "" } },
},
providerInfo: provider
? {
provider: provider.attributes.provider,
uid: provider.attributes.uid,
alias: provider.attributes.alias,
}
: undefined,
pendingSchedule: summary,
};
}
function getNumericValue(
source: Record<string, unknown>,
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<string, unknown>;
const nested =
typeof root.findings === "object" && root.findings !== null
? (root.findings as Record<string, unknown>)
: {};
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"]),
};
}