+
)}
diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx
index 30facaf97f..7eacabd713 100644
--- a/ui/components/findings/table/column-standalone-findings.tsx
+++ b/ui/components/findings/table/column-standalone-findings.tsx
@@ -192,12 +192,37 @@ export function getStandaloneFindingColumns({
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
+ const rawAlias = getProviderData(row, "alias");
+ const rawUid = getProviderData(row, "uid");
+ // getProviderData's union includes the provider's connection object;
+ // only string attribute values are renderable here.
+ const alias = typeof rawAlias === "string" ? rawAlias : "-";
+ const uid = typeof rawUid === "string" ? rawUid : "-";
+ // The icon alone cannot tell accounts of the same type apart — the
+ // cross-provider/cross-account drill-downs merge findings from
+ // several accounts into this one table, so each row carries its
+ // account label (alias when set, uid otherwise).
+ const label = alias !== "-" ? alias : uid;
return (
-
+
+
+ {label !== "-" && (
+
+
+
+ {label}
+
+
+
+ {alias !== "-" && uid !== "-" ? `${alias} (${uid})` : label}
+
+
+ )}
+
);
},
enableSorting: false,
diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx
index 5be780cc30..8a9cea8d02 100644
--- a/ui/components/layout/nav-bar/navbar-client.test.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.test.tsx
@@ -18,6 +18,11 @@ const navigationMocks = vi.hoisted(() => ({
const requestReplayMock = vi.hoisted(() => vi.fn());
+/** `replayFlow` reads the live query from `window` (not `useSearchParams`), so
+ * the CSR bailout stays inside the Suspense boundary NavbarClient renders. */
+const setLocationSearch = (search: string) =>
+ window.history.replaceState(null, "", `/${search}`);
+
vi.mock("next/navigation", () => ({
usePathname: () => navigationMocks.pathname,
useRouter: () => ({ push: navigationMocks.push }),
@@ -65,6 +70,7 @@ describe("NavbarClient", () => {
navigationMocks.push.mockClear();
requestReplayMock.mockClear();
navigationMocks.searchParams = new URLSearchParams();
+ setLocationSearch("");
window.localStorage.clear();
// Replay icon is Cloud-only.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -146,8 +152,34 @@ describe("NavbarClient", () => {
it("starts the replay in-memory (no navigation) when already on the flow's route", async () => {
// Given the user is already on the flow's page
+ navigationMocks.pathname = "/findings";
+ usePageReadyStore.setState({ readyPath: "/findings" });
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", {
+ name: /start product tour: explore your findings/i,
+ }),
+ );
+
+ // Then the replay is requested via the in-memory store — no router.push, so no
+ // `?onboarding=` URL param and no Next.js RSC refetch of the page.
+ expect(requestReplayMock).toHaveBeenCalledWith("explore-findings");
+ expect(navigationMocks.push).not.toHaveBeenCalled();
+ });
+
+ it("navigates when the flow pins a tab the current route does not carry", async () => {
+ // Given the user is on the bare /compliance route (Multiple Scans), while
+ // the tour anchors live on the Single Scan tab.
navigationMocks.pathname = "/compliance";
- navigationMocks.searchParams = new URLSearchParams("scanId=scan-1&foo=bar");
+ setLocationSearch("");
usePageReadyStore.setState({ readyPath: "/compliance" });
const user = userEvent.setup();
render(
@@ -164,8 +196,37 @@ describe("NavbarClient", () => {
}),
);
- // Then the replay is requested via the in-memory store — no router.push, so no
- // `?onboarding=` URL param and no Next.js RSC refetch of the page.
+ // Then a real navigation switches tabs before the tour starts.
+ expect(requestReplayMock).not.toHaveBeenCalled();
+ expect(navigationMocks.push).toHaveBeenCalledWith(
+ "/compliance?tab=per-scan&onboarding=view-compliance",
+ );
+ });
+
+ it("replays in-memory when the pinned tab is already active, keeping the selected scan", async () => {
+ // Given the user is on Single Scan with a scan and a filter picked — a
+ // navigation here would reset both, so the pinned tab must not force one.
+ navigationMocks.pathname = "/compliance";
+ setLocationSearch(
+ "?tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1",
+ );
+ usePageReadyStore.setState({ readyPath: "/compliance" });
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", {
+ name: /start product tour: check compliance/i,
+ }),
+ );
+
+ // Then
expect(requestReplayMock).toHaveBeenCalledWith("view-compliance");
expect(navigationMocks.push).not.toHaveBeenCalled();
});
diff --git a/ui/components/layout/nav-bar/navbar-client.tsx b/ui/components/layout/nav-bar/navbar-client.tsx
index 088e31ac33..c9fb80aaa9 100644
--- a/ui/components/layout/nav-bar/navbar-client.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.tsx
@@ -14,7 +14,7 @@ import {
} from "@/components/shadcn";
import { SidePanelTrigger } from "@/components/side-panel";
import { ThemeSwitch } from "@/components/ThemeSwitch";
-import { getFlowById } from "@/lib/onboarding";
+import { getFlowById, isOnFlowRoute } from "@/lib/onboarding";
import { isCloud } from "@/lib/shared/env";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
import { cn } from "@/lib/utils";
@@ -67,8 +67,13 @@ export function NavbarClient({
if (!flow) return;
// Already on the flow's page: start the replay via an in-memory signal so the
- // URL never changes and Next.js never refetches the (often heavy) page.
- if (flow.route === pathname) {
+ // URL never changes and Next.js never refetches the (often heavy) page — and
+ // the params the user built up (selected scan, filters) survive.
+ // The query is read from `window` rather than `useSearchParams()` on purpose:
+ // the hook would hoist the CSR bailout to this component, outside the Suspense
+ // boundary below that exists to contain it. This runs on click, client-only.
+ const currentQuery = new URLSearchParams(window.location.search);
+ if (isOnFlowRoute(flow.route, pathname, currentQuery)) {
requestReplay(flow.id);
return;
}
diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx
index de4e89ebdc..b01550b0e0 100644
--- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx
+++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx
@@ -282,7 +282,9 @@ describe("ScanJobsRowActions", () => {
);
// Then
- expect(pushMock).toHaveBeenCalledWith("/compliance?scanId=scan-1");
+ expect(pushMock).toHaveBeenCalledWith(
+ "/compliance?tab=per-scan&scanId=scan-1",
+ );
});
it("renames the completed scan report download action", async () => {
diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx
index 15d9bf0085..edc7503ba9 100644
--- a/ui/components/scans/table/scan-jobs-row-actions.tsx
+++ b/ui/components/scans/table/scan-jobs-row-actions.tsx
@@ -29,6 +29,7 @@ import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
+import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { toLocalDateString } from "@/lib/date-utils";
import { downloadScanZip } from "@/lib/helper";
import { getScanScheduleCapability } from "@/lib/schedules";
@@ -99,7 +100,7 @@ export function ScanJobsRowActions({
const openCompliance = () => {
if (!isCompleted) return;
- router.push(`/compliance?scanId=${scan.id}`);
+ router.push(buildPerScanComplianceHref({ scanId: scan.id }));
};
const openErrorDetails = async () => {
diff --git a/ui/components/shadcn/accordion/Accordion.tsx b/ui/components/shadcn/accordion/Accordion.tsx
index 5ac80ccc3d..107ed43b34 100644
--- a/ui/components/shadcn/accordion/Accordion.tsx
+++ b/ui/components/shadcn/accordion/Accordion.tsx
@@ -138,7 +138,10 @@ export const Accordion = ({
isCompact ? "py-2" : "py-3",
)}
>
-
+ {/* min-w-0 lets flex children of the title shrink/truncate;
+ without it a long requirement title forces the row past
+ the trigger's rounded border (flex min-width:auto). */}
+
{item.title}
{item.subtitle && (
diff --git a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx
index abe796009f..6121ee6f42 100644
--- a/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx
+++ b/ui/components/shadcn/breadcrumbs/breadcrumb-navigation.tsx
@@ -6,6 +6,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import { LighthouseIcon } from "@/components/icons/Icons";
+import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { cn } from "@/lib/utils";
export interface CustomBreadcrumbItem {
@@ -101,8 +102,10 @@ export function BreadcrumbNavigation({
const buildNavigationUrl = (path: string) => {
const paramValue = searchParams.get(paramToPreserve);
+ // A preserved scan only makes sense on Single Scan, which is no longer
+ // the landing tab: the bare route would drop it for Multiple Scans.
if (path === "/compliance" && paramValue) {
- return `/compliance?${paramToPreserve}=${paramValue}`;
+ return buildPerScanComplianceHref({ [paramToPreserve]: paramValue });
}
return path;
};
diff --git a/ui/components/shadcn/card/card.tsx b/ui/components/shadcn/card/card.tsx
index ee1174d061..c6264d9805 100644
--- a/ui/components/shadcn/card/card.tsx
+++ b/ui/components/shadcn/card/card.tsx
@@ -31,6 +31,10 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
xl: "p-8",
none: "p-0",
},
+ interactive: {
+ true: "cursor-pointer transition-shadow hover:shadow-md",
+ false: "",
+ },
},
compoundVariants: [
{
@@ -57,6 +61,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
defaultVariants: {
variant: "default",
padding: "default",
+ interactive: false,
},
});
@@ -64,11 +69,17 @@ interface CardProps
extends React.ComponentProps<"div">,
VariantProps
{}
-function Card({ className, variant, padding, ...props }: CardProps) {
+function Card({
+ className,
+ variant,
+ padding,
+ interactive,
+ ...props
+}: CardProps) {
return (
);
diff --git a/ui/components/shadcn/progress.tsx b/ui/components/shadcn/progress.tsx
index 3a549f4004..4423edcd3d 100644
--- a/ui/components/shadcn/progress.tsx
+++ b/ui/components/shadcn/progress.tsx
@@ -7,12 +7,21 @@ import { cn } from "@/lib/utils";
interface ProgressProps extends ComponentProps {
indicatorClassName?: string;
+ variant?: "default" | "success" | "warning" | "danger";
}
+const indicatorVariants = {
+ default: "bg-button-primary",
+ success: "bg-bg-pass",
+ warning: "bg-bg-warning",
+ danger: "bg-bg-fail",
+} as const;
+
function Progress({
className,
value = 0,
indicatorClassName,
+ variant = "default",
...props
}: ProgressProps) {
const normalizedValue = value ?? 0;
@@ -30,7 +39,8 @@ function Progress({
{
+ size?: "auto" | "md";
+}
+
const ScrollArea = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ ScrollAreaProps
+>(({ className, children, size = "auto", ...props }, ref) => (
diff --git a/ui/components/shared/task-polling-watcher.tsx b/ui/components/shared/task-polling-watcher.tsx
index 8624f0d9c4..b551a3fe36 100644
--- a/ui/components/shared/task-polling-watcher.tsx
+++ b/ui/components/shared/task-polling-watcher.tsx
@@ -1,5 +1,9 @@
"use client";
+import {
+ CROSS_ACCOUNT_PDF_TASK_KIND,
+ crossAccountPdfHandler,
+} from "@/app/(prowler)/compliance/_lib/cross-account-pdf";
import {
CROSS_PROVIDER_PDF_TASK_KIND,
crossProviderPdfHandler,
@@ -16,6 +20,7 @@ import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
// this tab. Adding a new watched task kind (integration tests, scan exports,
// …) is one line here plus a handler next to the feature that owns it.
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
+registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
/**
diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts
index b5ae3e55cb..9326fad631 100644
--- a/ui/lib/cloud-upgrade.ts
+++ b/ui/lib/cloud-upgrade.ts
@@ -83,6 +83,7 @@ export const CLOUD_UPGRADE_CONTENT = {
"Replace separate scan reports with a consolidated compliance view.",
benefits: [
"Compare framework posture across providers",
+ "Roll up every account in a Provider Group",
"Find coverage gaps without switching scans",
"Generate a consolidated compliance report",
],
diff --git a/ui/lib/compliance/compliance-tab-url.test.ts b/ui/lib/compliance/compliance-tab-url.test.ts
new file mode 100644
index 0000000000..b77d83696e
--- /dev/null
+++ b/ui/lib/compliance/compliance-tab-url.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { buildPerScanComplianceHref } from "./compliance-tab-url";
+
+describe("buildPerScanComplianceHref", () => {
+ it("pins the Single Scan tab when no extra params are given", () => {
+ expect(buildPerScanComplianceHref()).toBe("/compliance?tab=per-scan");
+ });
+
+ it("keeps the tab first and appends the extra params", () => {
+ expect(buildPerScanComplianceHref({ scanId: "scan-1" })).toBe(
+ "/compliance?tab=per-scan&scanId=scan-1",
+ );
+ });
+
+ it("encodes param values", () => {
+ expect(buildPerScanComplianceHref({ scanId: "scan 1&tab=bogus" })).toBe(
+ "/compliance?tab=per-scan&scanId=scan+1%26tab%3Dbogus",
+ );
+ });
+
+ it("ignores a caller-supplied tab so the pin cannot be defeated", () => {
+ expect(
+ buildPerScanComplianceHref({ tab: "cross-provider", scanId: "scan-1" }),
+ ).toBe("/compliance?tab=per-scan&scanId=scan-1");
+ });
+});
diff --git a/ui/lib/compliance/compliance-tab-url.ts b/ui/lib/compliance/compliance-tab-url.ts
new file mode 100644
index 0000000000..e3ce964b67
--- /dev/null
+++ b/ui/lib/compliance/compliance-tab-url.ts
@@ -0,0 +1,22 @@
+import { COMPLIANCE_TAB } from "@/types/compliance";
+
+/**
+ * Builds a `/compliance` URL pinned to the Single Scan tab.
+ *
+ * Multiple Scans is the default landing tab and owns the bare `/compliance`
+ * route, so every link that targets one concrete scan has to pin Single Scan
+ * explicitly or it lands on the aggregated view instead.
+ */
+export function buildPerScanComplianceHref(
+ params?: Record,
+): string {
+ // `tab` is owned by this helper: a caller-supplied one would silently defeat
+ // the pin the function name promises, so it is dropped rather than merged.
+ const extras = new URLSearchParams(params);
+ extras.delete("tab");
+
+ const search = new URLSearchParams({ tab: COMPLIANCE_TAB.PER_SCAN });
+ extras.forEach((value, key) => search.append(key, value));
+
+ return `/compliance?${search.toString()}`;
+}
diff --git a/ui/lib/onboarding/__tests__/flow-route.test.ts b/ui/lib/onboarding/__tests__/flow-route.test.ts
new file mode 100644
index 0000000000..a31f219f7d
--- /dev/null
+++ b/ui/lib/onboarding/__tests__/flow-route.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+
+import { isOnFlowRoute } from "../flow-route";
+
+const query = (search: string) => new URLSearchParams(search);
+
+describe("isOnFlowRoute", () => {
+ it("matches a plain route on the same pathname", () => {
+ expect(isOnFlowRoute("/findings", "/findings", query(""))).toBe(true);
+ });
+
+ it("ignores extra params the user built up", () => {
+ expect(
+ isOnFlowRoute(
+ "/compliance?tab=per-scan",
+ "/compliance",
+ query("tab=per-scan&scanId=scan-1&filter%5Bregion__in%5D=eu-west-1"),
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects a different pathname", () => {
+ expect(
+ isOnFlowRoute("/compliance?tab=per-scan", "/findings", query("")),
+ ).toBe(false);
+ });
+
+ it("rejects the same pathname when a pinned param is missing", () => {
+ expect(
+ isOnFlowRoute("/compliance?tab=per-scan", "/compliance", query("")),
+ ).toBe(false);
+ });
+
+ it("rejects the same pathname when a pinned param has another value", () => {
+ expect(
+ isOnFlowRoute("/scans?tab=active", "/scans", query("tab=scheduled")),
+ ).toBe(false);
+ });
+
+ it("requires every pinned param, not just the first", () => {
+ expect(
+ isOnFlowRoute(
+ "/scans?tab=active&view=list",
+ "/scans",
+ query("tab=active"),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/ui/lib/onboarding/__tests__/registry.test.ts b/ui/lib/onboarding/__tests__/registry.test.ts
index cb677a268d..ec298fa741 100644
--- a/ui/lib/onboarding/__tests__/registry.test.ts
+++ b/ui/lib/onboarding/__tests__/registry.test.ts
@@ -79,11 +79,11 @@ describe("onboardingFlows (production registry)", () => {
expect(flow?.tour.id).toBe("explore-findings");
});
- it("registers the view-compliance flow at order 4 on the compliance route", () => {
+ it("registers the view-compliance flow at order 4 on the single scan tab", () => {
const flow = getFlowById("view-compliance", onboardingFlows);
expect(flow).toBeDefined();
expect(flow?.order).toBe(4);
- expect(flow?.route).toBe("/compliance");
+ expect(flow?.route).toBe("/compliance?tab=per-scan");
expect(flow?.tour.id).toBe("view-compliance");
});
diff --git a/ui/lib/onboarding/flow-route.ts b/ui/lib/onboarding/flow-route.ts
new file mode 100644
index 0000000000..867c9d9c08
--- /dev/null
+++ b/ui/lib/onboarding/flow-route.ts
@@ -0,0 +1,33 @@
+/**
+ * Tells whether the browser is already showing a flow's route.
+ *
+ * A flow route may pin query params (`/compliance?tab=per-scan`,
+ * `/scans?tab=active`) that select the view its anchors live in, so a plain
+ * string compare against `pathname` never matches and the replay degrades into
+ * a full navigation — losing whatever params the user had built up.
+ *
+ * The pinned params are a subset check, not an equality one: extra params the
+ * user picked up (a selected scan, filters) still count as being on the route.
+ */
+export function isOnFlowRoute(
+ flowRoute: string,
+ pathname: string,
+ searchParams: URLSearchParams,
+): boolean {
+ const [routePath, routeQuery = ""] = flowRoute.split("?");
+
+ if (routePath !== pathname) {
+ return false;
+ }
+
+ // forEach, not for..of: URLSearchParams is only iterable under
+ // downlevelIteration, which this tsconfig does not enable.
+ let carriesPinnedParams = true;
+ new URLSearchParams(routeQuery).forEach((value, key) => {
+ if (searchParams.get(key) !== value) {
+ carriesPinnedParams = false;
+ }
+ });
+
+ return carriesPinnedParams;
+}
diff --git a/ui/lib/onboarding/index.ts b/ui/lib/onboarding/index.ts
index 8ba6906ff2..cd6907c036 100644
--- a/ui/lib/onboarding/index.ts
+++ b/ui/lib/onboarding/index.ts
@@ -2,5 +2,6 @@
export type { GateDecisionInput } from "./gate-decision";
export { shouldStartOnboarding } from "./gate-decision";
+export { isOnFlowRoute } from "./flow-route";
export type { OnboardingContext, OnboardingFlow } from "./onboarding-types";
export { getFlowById, getOrderedFlows, onboardingFlows } from "./registry";
diff --git a/ui/lib/onboarding/registry.ts b/ui/lib/onboarding/registry.ts
index 32a6a12afa..a11fb7c170 100644
--- a/ui/lib/onboarding/registry.ts
+++ b/ui/lib/onboarding/registry.ts
@@ -1,3 +1,4 @@
+import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { attackPathsTour } from "@/lib/tours/attack-paths.tour";
import { exploreFindingsTour } from "@/lib/tours/explore-findings.tour";
@@ -46,7 +47,9 @@ export const onboardingFlows: readonly OnboardingFlow[] = [
order: 4,
title: "Check compliance",
description: "Map your findings to frameworks like CIS.",
- route: "/compliance",
+ // Land on Single Scan: the framework cards the tour anchors to only
+ // render there, and the bare route now opens Multiple Scans.
+ route: buildPerScanComplianceHref(),
tour: viewComplianceTour,
dataRequirementHint: SCAN_DATA_HINT,
},
diff --git a/ui/lib/tours/__tests__/view-compliance.tour.test.ts b/ui/lib/tours/__tests__/view-compliance.tour.test.ts
index 4d6c042b51..f78cba202c 100644
--- a/ui/lib/tours/__tests__/view-compliance.tour.test.ts
+++ b/ui/lib/tours/__tests__/view-compliance.tour.test.ts
@@ -6,8 +6,7 @@ import {
type ViewComplianceTourTarget,
} from "../view-compliance.tour";
-// Only these two carry a `data-tour-id` in the UI; welcome step has no target.
-const ALLOWED_TARGETS = ["frameworks", "search"] as const;
+const ALLOWED_TARGETS = ["tabs", "frameworks", "search"] as const;
const definedTargets = (): ViewComplianceTourTarget[] =>
viewComplianceTour.steps
@@ -23,14 +22,14 @@ describe("viewComplianceTour shape", () => {
it("declares a positive integer version", () => {
expect(Number.isInteger(viewComplianceTour.version)).toBe(true);
- expect(viewComplianceTour.version).toBeGreaterThan(0);
+ expect(viewComplianceTour.version).toBe(2);
});
- it("anchors exactly the search and frameworks steps, in that order", () => {
+ it("introduces the scan modes before search and frameworks", () => {
// Search sits above the cards in the DOM; the tour must follow top-to-bottom
// so the spotlight never jumps back up the page.
const targets = definedTargets();
- expect(targets).toEqual(["search", "frameworks"]);
+ expect(targets).toEqual(["tabs", "search", "frameworks"]);
});
it("never targets an element outside the allowed anchor set", () => {
diff --git a/ui/lib/tours/view-compliance.tour.ts b/ui/lib/tours/view-compliance.tour.ts
index 5aa23c77ca..0a41a985f6 100644
--- a/ui/lib/tours/view-compliance.tour.ts
+++ b/ui/lib/tours/view-compliance.tour.ts
@@ -7,6 +7,7 @@ import {
// Const map keeps the union narrow so `useDriverTour` can validate step keys.
export const VIEW_COMPLIANCE_TOUR_TARGETS = {
+ TABS: "tabs",
FRAMEWORKS: "frameworks",
SEARCH: "search",
} as const;
@@ -16,7 +17,7 @@ export type ViewComplianceTourTarget =
export const viewComplianceTour = defineTour({
id: "view-compliance",
- version: 1,
+ version: 2,
coversFiles: [
"ui/app/(prowler)/compliance/**",
"ui/components/compliance/**",
@@ -27,6 +28,14 @@ export const viewComplianceTour = defineTour({
description:
"Compliance maps your findings to frameworks like CIS so you can see where you stand against each standard.",
},
+ {
+ target: "tabs",
+ side: TOUR_STEP_SIDES.BOTTOM,
+ align: TOUR_STEP_ALIGNMENTS.START,
+ title: "Choose how to combine scans",
+ description:
+ "Multiple Scans combines results across provider types or across accounts of the same provider type. Single Scan reviews one scan at a time.",
+ },
{
target: "search",
side: TOUR_STEP_SIDES.BOTTOM,
diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts
index 203ee77c54..2f0b4bb0d2 100644
--- a/ui/types/compliance.ts
+++ b/ui/types/compliance.ts
@@ -10,6 +10,17 @@ export const REQUIREMENT_STATUS = {
export type RequirementStatus =
(typeof REQUIREMENT_STATUS)[keyof typeof REQUIREMENT_STATUS];
+/** Tabs of the compliance overview page. Multiple Scans is the default
+ * landing tab, so it owns the bare `/compliance` route and Single Scan is
+ * reachable through `?tab=per-scan`. */
+export const COMPLIANCE_TAB = {
+ PER_SCAN: "per-scan",
+ CROSS_PROVIDER: "cross-provider",
+} as const;
+
+export type ComplianceTab =
+ (typeof COMPLIANCE_TAB)[keyof typeof COMPLIANCE_TAB];
+
export const COMPLIANCE_OVERVIEW_TYPE = {
OVERVIEW: "compliance-overviews",
REQUIREMENTS_STATUS: "compliance-requirements-status",