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 = { [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 => ({ "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>; 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 { const tab = getScanJobsTab(searchParams.tab); return Object.entries(searchParams).reduce>( (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 { 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; /** Providers that already have a real `state=scheduled` Scan row. */ coveredProviderIds: Set; 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 { 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 { const filters: Record = {}; 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, keys: string[], ): number | undefined { for (const key of keys) { const value = source[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return undefined; } export function getScanFindingsSummary( attributes: ScanAttributes, ): ScanFindingsSummary | null { const root = attributes as unknown as Record; const nested = typeof root.findings === "object" && root.findings !== null ? (root.findings as Record) : {}; const source = { ...root, ...nested }; const fail = getNumericValue(source, [ "fail", "failed", "failed_findings", "fail_findings", ]); const pass = getNumericValue(source, [ "pass", "passed", "passed_findings", "pass_findings", ]); if (fail === undefined || pass === undefined) return null; return { fail, pass, failNew: getNumericValue(source, ["fail_new", "new_failed_findings"]), passNew: getNumericValue(source, ["pass_new", "new_passed_findings"]), }; }