"use client"; import { usePathname, useSearchParams } from "next/navigation"; import { type ReactNode, Suspense, useState } from "react"; import { OnboardingTrigger, PageReady } from "@/components/onboarding"; import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; import { Button, Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/shadcn"; import { getFlowById } from "@/lib/onboarding"; import { LAUNCH_SCAN_SEARCH_PARAM, LAUNCH_SCAN_SEARCH_VALUE, } from "@/lib/scans-navigation"; import { isCloud } from "@/lib/shared/env"; import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour"; import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; import type { ProviderGroup } from "@/types/components"; import type { ProviderProps } from "@/types/providers"; import type { ScanScheduleCapability } from "@/types/schedules"; const viewFirstScanFlow = getFlowById("view-first-scan")!; import { CliImportBanner } from "./cli-import-banner"; import { ImportFindingsModal } from "./import-findings-modal"; import { LaunchScanModal } from "./launch-scan-modal"; import { ScansFilterBar } from "./scans-filter-bar"; import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; import { useScansFilters } from "./use-scans-filters"; interface ScansPageShellProps { providers: ProviderProps[]; providerGroups?: ProviderGroup[]; hasManageScansPermission: boolean; hasManageIngestionsPermission?: boolean; activeScanCount?: number; children: ReactNode; /** Cloud overlay seam for the launch-scan modal. */ scanScheduleCapability?: ScanScheduleCapability; isScanLimitReached?: boolean; } export function ScansPageShell({ providers, providerGroups = [], hasManageScansPermission, hasManageIngestionsPermission = false, activeScanCount = 0, children, scanScheduleCapability, isScanLimitReached, }: ScansPageShellProps) { const pathname = usePathname(); const searchParams = useSearchParams(); const [urlLaunchOpen, setUrlLaunchOpen] = useState( () => searchParams.get(LAUNCH_SCAN_SEARCH_PARAM) === LAUNCH_SCAN_SEARCH_VALUE, ); const isLaunchScanModalOpen = useScansStore( (state) => state.isLaunchScanModalOpen, ); const setLaunchScanModalOpen = useScansStore( (state) => state.setLaunchScanModalOpen, ); const filters = useScansFilters(); const hasConnectedProviders = providers.some( (provider) => provider.attributes.connection.connected === true, ); const thereAreNoProviders = providers.length === 0; // Non-blocking onboarding hint: shown when scans can't be launched (no provider, or // none connected). The table still renders below, so imported scans stay visible. const showProvidersHint = thereAreNoProviders || !hasConnectedProviders; const isCloudEnvironment = isCloud(); const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; const launchOpen = !launchDisabled && (isLaunchScanModalOpen || urlLaunchOpen); // When a scan is already running, the tour highlights its row (anchored in // ScanJobsTable); otherwise it falls back to the Launch Scan button + tabs. const hasInProgressScan = activeScanCount > 0; const getTabLabel = (tab: ScanJobsTab) => { const label = SCAN_TAB_LABELS[tab]; if (tab !== SCAN_JOBS_TAB.ACTIVE) return label; return `${label} (${activeScanCount})`; }; const handleLaunchOpenChange = (open: boolean) => { setLaunchScanModalOpen(open); if (open) return; setUrlLaunchOpen(false); // Remove ?launchScan via History API (not router.replace) to avoid an RSC // refetch that reloads the page; revalidatePath in scanOnDemand already // refreshes the scans list when a scan is launched. if (!searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) return; const params = new URLSearchParams(searchParams.toString()); params.delete(LAUNCH_SCAN_SEARCH_PARAM); const query = params.toString(); window.history.replaceState( null, "", query ? `${pathname}?${query}` : pathname, ); }; return (
{/* Gate on the full launch condition: the tour anchors on Launch Scan, which is disabled without a connected provider AND manage_scans — starting it would point at an unusable button. Every start path (?onboarding= URL, active sequence, navbar replay) funnels through this trigger, so gating here covers them all. */} {/* Suspense required: OnboardingTrigger reads useSearchParams */} {!launchDisabled && ( )} {/* Signals the navbar that this route's data has loaded (enables the replay icon). */} {showProvidersHint && ( )}
{isCloudEnvironment && hasManageIngestionsPermission && ( )}
{isCloudEnvironment && }
{Object.values(SCAN_JOBS_TAB).map((tab) => ( {getTabLabel(tab as ScanJobsTab)} ))}
{children}
); }