diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 918755a629..2dc50a26bb 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -131,6 +131,10 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run healthcheck + - name: Check product-tour alignment + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run tour:check + - name: Run pnpm audit if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run audit diff --git a/AGENTS.md b/AGENTS.md index 1cf023889c..c9d63a8c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Use these skills for detailed patterns on-demand: | `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) | | `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) | | `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | +| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) | | `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -67,10 +68,12 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Adding new providers | `prowler-provider` | | Adding privilege escalation detection queries | `prowler-attack-paths-query` | | Adding services to existing providers | `prowler-provider` | +| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` | | After creating/modifying a skill | `skill-sync` | | App Router / Server Actions | `nextjs-16` | | Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` | | Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | | Committing changes | `prowler-commit` | | Configuring MCP servers in agentic workflows | `gh-aw` | | Create PR that requires changelog entry | `prowler-changelog` | @@ -89,6 +92,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating/updating compliance frameworks | `prowler-compliance` | | Debug why a GitHub Actions job is failing | `prowler-ci` | | Debugging gh-aw compilation errors | `gh-aw` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | | Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` | | Fixing bug | `tdd` | | Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` | @@ -105,6 +109,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Refactoring code | `tdd` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | diff --git a/skills/prowler-tour/SKILL.md b/skills/prowler-tour/SKILL.md new file mode 100644 index 0000000000..8511581a47 --- /dev/null +++ b/skills/prowler-tour/SKILL.md @@ -0,0 +1,99 @@ +--- +name: prowler-tour +description: > + Keeps product-tour definitions aligned with the UI features they describe. + Trigger: When modifying UI components that have associated tours, editing tour + definition files, or renaming data-tour-id attributes. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: + - "Editing a UI file containing data-tour-id attributes" + - "Adding, updating, or removing a tour definition (*.tour.ts)" + - "Renaming or removing a data-tour-id attribute value" + - "Changing button labels or section headings on a tour-covered page" + - "Restructuring routes or layouts covered by a tour" +allowed-tools: Read, Glob, Grep +--- + +# prowler-tour + +**Report-only.** This skill never edits tour files or UI files; it inspects +the change, reports drift it finds between tours and the covered UI, and +recommends actions for the developer to apply. + +## Early-exit rule + +Run this check first. Most UI edits are not tour-related — exit cheaply. + +1. Glob `ui/lib/tours/*.tour.ts`. +2. For each tour, check whether any `coversFiles` glob pattern matches any + file in the current change. +3. If no tour matches, respond **exactly**: + + > No tour affected — skipping alignment check + + and exit. Do not proceed to the checklist. +4. If at least one tour matches, continue to "Drift checklist" for that tour. + +## Drift checklist + +For each affected tour, evaluate every item. Skip items that obviously do +not apply, but list explicitly which items were checked. + +1. **Orphan selectors** — every step's `target` (which composes to + `data-tour-id="-"`) must resolve to a real element + in the codebase. Grep `ui/` for the expected attribute value; report + any step whose target is missing. +2. **Renamed selectors** — a `data-tour-id` attribute was edited in this + change. Match it back to any tour step referencing the old value. +3. **Outdated copy** — a popover `title`/`description` references a button + label, heading, or term that no longer exists on the covered page. +4. **Obsolete steps** — a step describes a section, panel, or workflow + that was removed. +5. **Missing steps** — a new feature was added on the covered surface + without a corresponding step (e.g. a new panel, a new primary action, + a new wizard stage). +6. **Reordered flow** — the user's path through the feature changed (e.g. + query builder moved before scan selection) and the step order no + longer reflects it. + +## Version-bump decision tree + +Apply per tour after listing drift: + +- **NO bump** when the change is cosmetic. Examples: fix a typo, soften + copy, rename a `data-tour-id` selector while keeping the same step, + swap one screenshot for another, tighten wording. +- **BUMP `version`** when the user-visible flow changes materially. + Examples: a new step was added or removed; the order changed; an + anchored target was retargeted to a different panel; the tour now + covers a new feature on the surface. + +When in doubt, ask: "Would a user who already saw the previous version +miss something useful by not seeing this one?" If yes, bump. + +## Output format + +When emitting a report, follow the exact structure in +`references/output-format.md`. The structure is mandatory because the +report is consumed downstream and tolerates no field reordering. + +## What this skill MUST NOT do + +- Do not edit `*.tour.ts` files. This skill is report-only. +- Do not edit UI files to add or rename `data-tour-id` attributes. +- Do not invent new tours. Authoring a new tour is a separate, deliberate + decision — the developer makes it, not the skill. +- Do not flag drift in tours whose `coversFiles` do not match any file + in the current change. Stick to the early-exit rule. + +## See also + +- `references/output-format.md` — exact report template (read when + emitting a report). +- `references/tours-architecture.md` — code map for the tour abstraction + under `ui/lib/tours/`. +- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`. diff --git a/skills/prowler-tour/assets/tour-template.ts b/skills/prowler-tour/assets/tour-template.ts new file mode 100644 index 0000000000..cc264a4678 --- /dev/null +++ b/skills/prowler-tour/assets/tour-template.ts @@ -0,0 +1,51 @@ +// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/` +/** + * Tour template — copy this file to `ui/lib/tours/.tour.ts` and + * fill in the placeholders. See `references/tours-architecture.md` for the + * design context. + * + * Conventions: + * - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS + * preserves the literal union of `target` values. `useDriverTour` uses + * that union to validate `stepHandlers` keys and `waitForStep` args. + * - `id` is kebab-case and unique across all tours. + * - Anchored steps reference DOM via `data-tour-id="-"`; + * the hook composes the CSS selector automatically. + * - `coversFiles` lists the globs that describe the tour's surface; the + * `prowler-tour` skill consumes this to decide whether to evaluate + * drift on a given change. + * - Material flow changes bump `version`; cosmetic edits do not. + */ +import { + defineTour, + TOUR_STEP_ALIGNMENTS, + TOUR_STEP_SIDES, +} from "@/lib/tours/tour-types"; + +export const yourTour = defineTour({ + id: "your-tour-id", + version: 1, + coversFiles: [ + // List the UI files this tour describes, using globs under `ui/`. + // Example: "ui/app/(prowler)/your-feature/**" + ], + steps: [ + { + // Modal step — no anchor. Use for intros, outros, and any step + // that does not point at a specific DOM element. + title: "Welcome", + description: "Short, plain-English description.", + }, + { + // Anchored step. The hook resolves + // `[data-tour-id="your-tour-id-step-name"]` lazily, so the element + // can be conditionally rendered as long as it exists when the step + // becomes active. + target: "step-name", + side: TOUR_STEP_SIDES.BOTTOM, + align: TOUR_STEP_ALIGNMENTS.START, + title: "Where the action is", + description: "Tell the user what to look at here and why.", + }, + ], +}); diff --git a/skills/prowler-tour/references/output-format.md b/skills/prowler-tour/references/output-format.md new file mode 100644 index 0000000000..ecd82c4d8e --- /dev/null +++ b/skills/prowler-tour/references/output-format.md @@ -0,0 +1,31 @@ +# Tour Alignment Report — output format + +The report is consumed downstream. Field names, order, and headings are +load-bearing — do not rename, reorder, or omit them. + +## Template + +```text +## Tour Alignment Report +**Tour:** `@v` +**Files touched:** + +### Drift detected +- + +### Recommended actions +1. + +### Version bump verdict +- +``` + +## Rules + +- One report per affected tour. If multiple tours are affected, separate + reports with a `---` line. +- If no drift is detected for an affected tour, still emit the report: + put "No drift detected." under "Drift detected" and "None required." + under "Recommended actions". The verdict line is still mandatory. +- The verdict is exactly one of `BUMP` or `NO bump` — see the + version-bump decision tree in `SKILL.md`. diff --git a/skills/prowler-tour/references/tours-architecture.md b/skills/prowler-tour/references/tours-architecture.md new file mode 100644 index 0000000000..9a5c8f49e0 --- /dev/null +++ b/skills/prowler-tour/references/tours-architecture.md @@ -0,0 +1,44 @@ +# Tours Architecture + +The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/). +This skill operates on tour definitions that follow this architecture. + +## Code map + +| File | Purpose | +|---|---| +| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. | +| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. | +| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. | +| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour..v`. | +| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. | +| `ui/lib/tours/.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. | +| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. | + +## Selector convention + +Tour steps anchor via `data-tour-id="-"`. The hook +composes the CSS selector at runtime; tour authors only provide the step +name in `step.target`. Class-based, ID-based, structural selectors are +forbidden — they couple tours to styling decisions that legitimately +change. + +## Identity and versioning + +A tour is `{ id, version }`. The localStorage key composes both. A +**material content change** bumps `version`; cosmetic edits do not. The +decision tree lives in the parent SKILL.md. + +## Persistence scope + +Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant +A does not see the tour again in tenant B, even if they can access the +feature there. The future `UserTourState` model (documented in +`design.md`, not built) is FK to `User`, not `Membership`. + +## Drift = #1 risk + +Without the maintenance skill + the optional CI gate +(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the +covered UI evolves. The parent SKILL.md enumerates the six drift +categories the skill checks for. diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 38af45860f..7c06cc56b1 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -14,40 +14,46 @@ > - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors > - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library > - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks) +> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI ## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: -| Action | Skill | -| -------------------------------------------------------------- | ------------------- | -| Add changelog entry for a PR or feature | `prowler-changelog` | -| App Router / Server Actions | `nextjs-16` | -| Building AI chat features | `ai-sdk-5` | -| Committing changes | `prowler-commit` | -| Create PR that requires changelog entry | `prowler-changelog` | -| Creating Zod schemas | `zod-4` | -| Creating a git commit | `prowler-commit` | -| Creating/modifying Prowler UI components | `prowler-ui` | -| Fixing bug | `tdd` | -| Implementing feature | `tdd` | -| Modifying component | `tdd` | -| Refactoring code | `tdd` | -| Review changelog format and conventions | `prowler-changelog` | -| Testing hooks or utilities | `vitest` | -| Update CHANGELOG.md in any component | `prowler-changelog` | -| Using Zustand stores | `zustand-5` | -| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | -| Working on task | `tdd` | -| Working with Prowler UI test helpers/pages | `prowler-test-ui` | -| Working with Tailwind classes | `tailwind-4` | -| Writing Playwright E2E tests | `playwright` | -| Writing Prowler UI E2E tests | `prowler-test-ui` | -| Writing React component tests | `vitest` | -| Writing React components | `react-19` | -| Writing TypeScript types/interfaces | `typescript` | -| Writing Vitest tests | `vitest` | -| Writing unit tests for UI | `vitest` | +| Action | Skill | +| ----------------------------------------------------------------- | ------------------- | +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` | +| App Router / Server Actions | `nextjs-16` | +| Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying Prowler UI components | `prowler-ui` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | +| Fixing bug | `tdd` | +| Implementing feature | `tdd` | +| Modifying component | `tdd` | +| Refactoring code | `tdd` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | +| Review changelog format and conventions | `prowler-changelog` | +| Testing hooks or utilities | `vitest` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Using Zustand stores | `zustand-5` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working on task | `tdd` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing React component tests | `vitest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing Vitest tests | `vitest` | +| Writing unit tests for UI | `vitest` | --- diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index ede3aedff4..dbf87d055e 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to the **Prowler UI** are documented in this file. - DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Guided product onboarding for new users — step-by-step tours covering providers, scans, findings, compliance, and attack paths, replayable anytime from the info icon in the page header [(#11430)](https://github.com/prowler-cloud/prowler/pull/11430) ### 🔄 Changed diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 318004ea96..fb32a6286e 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -15,6 +15,7 @@ import { } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { SCAN_STATES } from "@/types/attack-paths"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; export const getScans = async ({ @@ -64,6 +65,10 @@ export const getScansByState = async () => { "filter[provider_type__in]", sanitizeProviderTypesCsv(), ); + // Only need to know whether at least one completed scan exists; filter server-side + // and cap to a single row so the answer is correct regardless of total scan count. + url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); + url.searchParams.append("page[size]", "1"); try { const response = await fetch(url.toString(), { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx index 4aec4b97d3..5ec29558ff 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/components/ui/table", () => ({ data, metadata, controlledPage, + getRowAttributes, }: { columns: Array<{ id?: string; @@ -74,6 +75,10 @@ vi.mock("@/components/ui/table", () => ({ }; }; controlledPage: number; + getRowAttributes?: (row: { + index: number; + original: AttackPathScan; + }) => Record; }) => (
{metadata.pagination.count} Total Entries @@ -95,8 +100,8 @@ vi.mock("@/components/ui/table", () => ({ - {data.map((row) => ( - + {data.map((row, index) => ( + {columns.map((column, index) => ( {column.cell @@ -176,6 +181,20 @@ describe("ScanListTable", () => { ); }); + it("anchors the attack paths scan tour to the first visible scan row", () => { + render( + , + ); + + const firstRow = screen + .getAllByRole("radio", { + name: "Select scan", + })[0] + .closest("tr"); + + expect(firstRow).toHaveAttribute("data-tour-id", "attack-paths-scan-list"); + }); + it("enables the radio button for a failed scan when graph data is ready", async () => { const user = userEvent.setup(); const failedScan: AttackPathScan = { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index 00a330810f..dd532f9ed6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -295,6 +295,9 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => { handleSelectScan(row.original.id); } }} + getRowAttributes={(row) => + row.index === 0 ? { "data-tour-id": "attack-paths-scan-list" } : {} + } enableRowSelection rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)} /> diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts index eec8e5f81a..c30edb519c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts @@ -1,3 +1,4 @@ +export { useAttackPathScans } from "./use-attack-path-scans"; export { useGraphState } from "./use-graph-state"; export { useQueryBuilder } from "./use-query-builder"; export { useWizardState } from "./use-wizard-state"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts new file mode 100644 index 0000000000..39515698a1 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; + +import { getAttackPathScans } from "@/actions/attack-paths"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { AttackPathScan } from "@/types/attack-paths"; + +export interface UseAttackPathScansOptions { + /** + * Invoked once the initial load resolves with no scan whose graph data is + * ready (including empty results or a fetch failure). The page passes a + * redirect only during onboarding replay; an established user gets `undefined` + * and stays on the page. + */ + onNoReadyScan?: () => void; +} + +export interface UseAttackPathScansResult { + scans: AttackPathScan[]; + scansLoading: boolean; + refreshScans: () => Promise; +} + +/** + * `useData`-style hook owning the Attack Paths scan list. The direct + * `useEffect` (via `useMountEffect`) lives here, not in the component: the + * project forbids `useEffect` in components, but a reusable data hook is the + * sanctioned place for a mount-time fetch when no fetching library is wired up. + */ +export function useAttackPathScans( + options: UseAttackPathScansOptions = {}, +): UseAttackPathScansResult { + const { onNoReadyScan } = options; + + const [scans, setScans] = useState([]); + const [scansLoading, setScansLoading] = useState(true); + + const refreshScans = async () => { + try { + const scansData = await getAttackPathScans(); + if (scansData?.data) { + setScans(scansData.data); + } + } catch (error) { + console.error("Failed to refresh scans:", error); + } + }; + + useMountEffect(() => { + let active = true; + + const loadScans = async () => { + setScansLoading(true); + try { + const scansData = await getAttackPathScans(); + const nextScans = scansData?.data ?? []; + if (!active) return; + setScans(nextScans); + if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) { + onNoReadyScan?.(); + } + } catch (error) { + if (!active) return; + console.error("Failed to load scans:", error); + setScans([]); + onNoReadyScan?.(); + } finally { + if (active) setScansLoading(false); + } + }; + + void loadScans(); + + return () => { + active = false; + }; + }); + + return { scans, scansLoading, refreshScans }; +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx deleted file mode 100644 index 8b91c3fb6e..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -describe("AttackPathsPage", () => { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(currentDir, "attack-paths-page.tsx"); - const source = readFileSync(filePath, "utf8"); - - it("keeps the page description without rendering a duplicate Attack Paths heading", () => { - // Then - expect(source).not.toContain(">\n Attack Paths\n "); - expect(source).toContain( - "Select a scan, build a query, and visualize Attack Paths in your", - ); - }); -}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 992fdee5ef..07f36912e4 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, Info, Maximize2 } from "lucide-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -10,11 +10,11 @@ import { buildAttackPathQueries, executeCustomQuery, executeQuery, - getAttackPathScans, getAvailableQueries, } from "@/actions/attack-paths"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; import { FindingDetailDrawer } from "@/components/findings/table"; +import { PageReady } from "@/components/onboarding"; import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; import { @@ -32,10 +32,19 @@ import { DialogTrigger, } from "@/components/shadcn/dialog"; import { useToast } from "@/components/ui"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { isCloud } from "@/lib/shared/env"; +import { + attackPathsTour, + type AttackPathsTourTarget, + pickDemoQuery, + pickDemoScan, +} from "@/lib/tours/attack-paths.tour"; +import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; +import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour"; import type { AttackPathQuery, AttackPathQueryError, - AttackPathScan, GraphNode, } from "@/types/attack-paths"; import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths"; @@ -53,23 +62,30 @@ import { ScanListTable, } from "./_components"; import type { GraphHandle } from "./_components/graph/attack-path-graph"; +import { useAttackPathScans } from "./_hooks/use-attack-path-scans"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsPNG } from "./_lib"; -/** - * Attack Paths - * Allows users to select a scan, build a query, and visualize the attack path graph - */ export default function AttackPathsPage() { const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); const scanId = searchParams.get("scanId"); + // Onboarding tours are Cloud-only. + const onboardingEnabled = isCloud(); + const isAttackPathsReplay = + onboardingEnabled && searchParams.get("onboarding") === "attack-paths"; const graphState = useGraphState(); const finding = useFindingDetails(); const { toast } = useToast(); - const [scansLoading, setScansLoading] = useState(true); - const [scans, setScans] = useState([]); + const { scans, scansLoading, refreshScans } = useAttackPathScans({ + onNoReadyScan: isAttackPathsReplay + ? () => router.push("/scans?onboarding=view-first-scan") + : undefined, + }); + const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); @@ -81,10 +97,62 @@ export default function AttackPathsPage() { const [queries, setQueries] = useState([]); - // Use custom hook for query builder form state and validation const queryBuilder = useQueryBuilder(queries); - // Reset graph state when component mounts + const hasReadyScan = scans.some((scan) => scan.attributes.graph_data_ready); + const hasNoScans = scans.length === 0; + + useDriverTour(attackPathsEmptyTour, { + enabled: onboardingEnabled && !scansLoading && hasNoScans, + }); + + const { start: startAttackPathsTour } = useDriverTour( + attackPathsTour, + { + enabled: onboardingEnabled && !scansLoading && hasReadyScan, + autoOpen: !isAttackPathsReplay, + // Page owns tour auto-open; OnboardingSequenceBanner is the sole Continue/Skip control. + // pickDemoScan/pickDemoQuery policy lives in attack-paths.tour.ts. + stepHandlers: { + "scan-list": { + onNext: async ({ waitForStep }) => { + const selected = pickDemoScan(scans); + if (!selected) return; + const params = new URLSearchParams(searchParams.toString()); + params.set("scanId", selected.id); + router.push(`${pathname}?${params.toString()}`); + await waitForStep("query-selector"); + }, + }, + "query-selector": { + onNext: async ({ waitForStep }) => { + const selected = pickDemoQuery(queries); + if (!selected) return; + queryBuilder.handleQueryChange(selected.id); + await waitForStep("execute-button"); + }, + }, + }, + }, + ); + + // Onboarding replay entry: start the tour once and strip the `onboarding` + // param. Invoked from , which mounts only when the + // replay conditions hold — so `useMountEffect` fires it exactly once and the + // old `replayStartedRef` run-once guard is gone. + const startAttackPathsReplay = () => { + startAttackPathsTour(); + + const params = new URLSearchParams(searchParams.toString()); + params.delete("onboarding"); + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `${pathname}?${query}` : pathname, + ); + }; + useEffect(() => { if (!hasResetRef.current) { hasResetRef.current = true; @@ -92,60 +160,22 @@ export default function AttackPathsPage() { } }, [graphState]); - // Reset graph state when scan changes useEffect(() => { graphState.resetGraph(); }, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only - // Load available scans on mount - useEffect(() => { - const loadScans = async () => { - setScansLoading(true); - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } else { - setScans([]); - } - } catch (error) { - console.error("Failed to load scans:", error); - setScans([]); - } finally { - setScansLoading(false); - } - }; - - loadScans(); - }, []); - - // Check if there's an executing scan for auto-refresh const hasExecutingScan = scans.some( (scan) => scan.attributes.state === SCAN_STATES.EXECUTING || scan.attributes.state === SCAN_STATES.SCHEDULED, ); - // Detect if the selected scan is showing data from a previous cycle const selectedScan = scans.find((scan) => scan.id === scanId); const isViewingPreviousCycleData = selectedScan && selectedScan.attributes.graph_data_ready && selectedScan.attributes.state !== SCAN_STATES.COMPLETED; - // Callback to refresh scans (used by AutoRefresh component) - const refreshScans = async () => { - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } - } catch (error) { - console.error("Failed to refresh scans:", error); - } - }; - - // Load available queries on mount useEffect(() => { const loadQueries = async () => { if (!scanId) { @@ -205,7 +235,6 @@ export default function AttackPathsPage() { return; } - // Validate form before executing query const isValid = await queryBuilder.form.trigger(); if (!isValid) { showErrorToast( @@ -215,6 +244,9 @@ export default function AttackPathsPage() { return; } + // The tour's execute step is autoAdvance: the real Execute click moves it forward. + advanceActiveTour(); + graphState.startLoading(); graphState.setError(null); @@ -257,7 +289,6 @@ export default function AttackPathsPage() { variant: "default", }); - // Scroll to graph after successful query execution setTimeout(() => { graphContainerRef.current?.scrollIntoView({ behavior: "smooth", @@ -297,13 +328,9 @@ export default function AttackPathsPage() { } findingNavigationInFlightRef.current = true; - // Findings skip the intermediate node-details modal. The finding drawer - // is the useful destination, so open it directly from the graph click. + // Open finding drawer directly, bypassing the node-details modal. graphState.enterFilteredView(node.id); - // enterFilteredView stores the filtered node as selected so the graph can - // highlight it. Clear the selection right after for findings so the node - // details modal does not open before the finding drawer. - graphState.selectNode(null); + graphState.selectNode(null); // clear so node-details modal doesn't open first void handleViewFinding(String(node.properties?.id || node.id)); return; } @@ -368,14 +395,19 @@ export default function AttackPathsPage() { return (
- {/* Auto-refresh scans when there's an executing scan */} - {/* Page introduction */} -
+ {isAttackPathsReplay && !scansLoading && hasReadyScan && ( + + )} + + {/* Enables the navbar replay icon once the initial scan load resolves. */} + {!scansLoading && } + +

Select a scan, build a query, and visualize Attack Paths in your infrastructure. @@ -390,27 +422,27 @@ export default function AttackPathsPage() {

Loading scans...

- ) : scans.length === 0 ? ( - - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - + ) : hasNoScans ? ( +
+ + + No scans available + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + +
) : ( <> - {/* Scans Table */} Loading scans...
}> - {/* Banner: viewing data from a previous scan cycle */} {isViewingPreviousCycleData && ( @@ -425,7 +457,6 @@ export default function AttackPathsPage() { )} - {/* Query Builder Section - shown only after selecting a scan */} {scanId && (
{queriesLoading ? ( @@ -438,11 +469,13 @@ export default function AttackPathsPage() { ) : ( <> - +
+ +
{queryBuilder.selectedQueryData && ( -
+
)} - {/* Graph Visualization (Full Width) */} {(graphState.loading || (graphState.data && graphState.data.nodes && @@ -488,7 +523,6 @@ export default function AttackPathsPage() { graphState.data.nodes && graphState.data.nodes.length > 0 ? ( <> - {/* Info message and controls */}
{graphState.isFilteredView ? (
@@ -537,7 +571,6 @@ export default function AttackPathsPage() {
)} - {/* Graph controls and fullscreen button together */}
graphRef.current?.zoomIn()} @@ -546,7 +579,6 @@ export default function AttackPathsPage() { onExport={() => handleGraphExport("main")} /> - {/* Fullscreen button */}
- {/* Graph in the middle */}
- {/* Legend below */}
); } + +interface AttackPathsReplayTriggerProps { + onReplay: () => void; +} + +// Conditional-mount trigger: the parent renders this only when the replay +// should start. The microtask keeps driver.js/flushSync outside React's +// mount lifecycle while still running before the next browser task. +function AttackPathsReplayTrigger({ onReplay }: AttackPathsReplayTriggerProps) { + useMountEffect(() => { + let cancelled = false; + + queueMicrotask(() => { + if (!cancelled) onReplay(); + }); + + return () => { + cancelled = true; + }; + }); + + return null; +} diff --git a/ui/app/(prowler)/attack-paths/layout.tsx b/ui/app/(prowler)/attack-paths/layout.tsx index 5ff93faab2..3a9a90b6d1 100644 --- a/ui/app/(prowler)/attack-paths/layout.tsx +++ b/ui/app/(prowler)/attack-paths/layout.tsx @@ -6,7 +6,11 @@ export default function AttackPathsLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index a67509bed1..d4ee609a0c 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -46,16 +46,26 @@ export default async function Compliance({ }); if (!scansData?.data) { - return ; + return ( + + + + ); } - // Process scans with provider information from included data const expandedScansData: ExpandedScanData[] = scansData.data .filter((scan: ScanProps) => scan.relationships?.provider?.data?.id) .map((scan: ScanProps) => { const providerId = scan.relationships!.provider!.data!.id; - // Find the provider data in the included array const providerData = scansData.included?.find( (item: { type: string; id: string }) => item.type === "providers" && item.id === providerId, @@ -76,15 +86,20 @@ export default async function Compliance({ }) .filter(Boolean) as ExpandedScanData[]; - // Use scanId from URL, or select the first scan if not provided const scanIdParam = resolvedSearchParams.scanId; const scanIdFromUrl = Array.isArray(scanIdParam) ? scanIdParam[0] : scanIdParam; const selectedScanId: string | null = scanIdFromUrl || expandedScansData[0]?.id || null; + const onboardingAction = selectedScanId + ? { flowId: "view-compliance" } + : { + flowId: "view-compliance", + fallbackFlowId: "view-first-scan", + useFallback: true, + }; - // Find the selected scan const selectedScan = expandedScansData.find( (scan) => scan.id === selectedScanId, ); @@ -100,7 +115,6 @@ export default async function Compliance({ } : undefined; - // Fetch metadata if we have a selected scan const metadataInfoData = selectedScanId ? await getComplianceOverviewMetadataInfo({ filters: { @@ -111,7 +125,6 @@ export default async function Compliance({ const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; - // Fetch ThreatScore data from API if we have a selected scan let threatScoreData = null; if (selectedScanId && typeof selectedScanId === "string") { const threatScoreResponse = await getThreatScore({ @@ -128,10 +141,13 @@ export default async function Compliance({ } return ( - + {selectedScanId ? ( <> - {/* Row 1: Filters */}
- {/* Row 2: ThreatScore card — full width, horizontal */} {threatScoreData && typeof selectedScanId === "string" && selectedScan && ( @@ -155,7 +170,6 @@ export default async function Compliance({
)} - {/* Row 3: Compliance grid with client-side search */} { const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; - // Only fetch compliance data if we have a valid scanId const compliancesData = scanId && scanId.trim() !== "" ? await getCompliancesOverview({ @@ -207,7 +220,6 @@ const SSRComplianceGrid = async ({ a.attributes.framework.localeCompare(b.attributes.framework), ); - // Check if the response contains no data if ( !compliancesData || !compliancesData.data || @@ -225,7 +237,6 @@ const SSRComplianceGrid = async ({ ); } - // Handle errors returned by the API if (compliancesData?.errors?.length > 0) { return ( @@ -235,10 +246,7 @@ const SSRComplianceGrid = async ({ ); } - // Compute the set of latest CIS variants per provider once, so each card - // can gate its PDF button without re-parsing on every render. The backend - // only generates a CIS PDF for the latest version per provider, so any - // other CIS card must not expose the PDF download button. + // Backend only generates CIS PDFs for the latest version per provider. const latestCisIds = pickLatestCisPerProvider( compliancesData.data.map( (compliance: ComplianceOverviewData) => compliance.id, diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 7ff9780292..65e8eca9cd 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -59,7 +59,6 @@ export default async function Findings({ filters: resolvedFilters, }); - // Extract unique regions, services, categories, groups from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = @@ -67,7 +66,6 @@ export default async function Findings({ const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; - // Extract scan UUIDs with "completed" state and more than one resource const completedScans = scansData?.data?.filter( (scan: ScanProps) => scan.attributes.state === "completed" && @@ -76,6 +74,14 @@ export default async function Findings({ const completedScanIds = completedScans?.map((scan: ScanProps) => scan.id) || []; + const onboardingAction = + completedScanIds.length > 0 + ? { flowId: "explore-findings" } + : { + flowId: "explore-findings", + fallbackFlowId: "view-first-scan", + useFallback: true, + }; const scanDetails = createScanDetailsMapping( completedScans || [], @@ -84,7 +90,11 @@ export default async function Findings({ const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; return ( - +
g.id).join(","); return ( diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 958dfc80d6..a093232e1c 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -2,16 +2,24 @@ import "@/styles/globals.css"; import * as Sentry from "@sentry/nextjs"; import { Metadata, Viewport } from "next"; -import { ReactNode } from "react"; +import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; +import { getScansByState } from "@/actions/scans/scans"; +import { + OnboardingCheckpointWatcher, + OnboardingGate, + OnboardingSequenceBanner, +} from "@/components/onboarding"; import MainLayout from "@/components/ui/main-layout/main-layout"; import { NavigationProgress } from "@/components/ui/navigation-progress"; import { Toaster } from "@/components/ui/toast"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; +import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { StoreInitializer } from "@/store/ui/store-initializer"; +import { SCAN_STATES } from "@/types/attack-paths"; import { Providers } from "../providers"; @@ -41,8 +49,30 @@ export default async function RootLayout({ }: { children: ReactNode; }) { - const providersData = await getProviders({ page: 1, pageSize: 1 }); - const hasProviders = !!(providersData?.data && providersData.data.length > 0); + // Onboarding is Cloud-only; skip its fetches and orchestrators in OSS. + const onboardingEnabled = isCloud(); + + // Fail-open: unknown scan state is treated as "has data" so the banner never blocks + // progression on a fetch error. + let hasCompletedScan = true; + // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). + let hasProviders: boolean | undefined = false; + + if (onboardingEnabled) { + const [providersData, scansByState] = await Promise.all([ + getProviders({ page: 1, pageSize: 1 }), + getScansByState(), + ]); + hasCompletedScan = Array.isArray(scansByState?.data) + ? scansByState.data.some( + (scan: { attributes?: { state?: string } }) => + scan.attributes?.state === SCAN_STATES.COMPLETED, + ) + : true; + hasProviders = Array.isArray(providersData?.data) + ? providersData.data.length > 0 + : undefined; + } return ( @@ -55,8 +85,22 @@ export default async function RootLayout({ )} > - - + {/* Suspense contains the useSearchParams() CSR bailout so statically + prerendered pages don't fail the build (matches the auth layout). */} + + + + {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} + + {onboardingEnabled && ( + <> + + {/* Single mount point so the watcher survives post-connect navigation. */} + + {/* Persistent banner shown only while a guided sequence is active. */} + + + )} {children} diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 0ec08e5e11..e6186df10a 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -22,12 +22,22 @@ export default async function Providers({ const activeTab = getProviderTab(resolvedSearchParams.tab); const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; - // Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend - const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {}; - const searchParamsKey = JSON.stringify(paramsWithoutTab); + // Exclude `tab` and `onboarding` from the key: tab switches must not re-suspend, + // and `onboarding` is ephemeral (stripped via history.replaceState) — keeping it + // would remount ProvidersAccountsView and reset the wizard mid-flow. + const { + tab: _tab, + onboarding: _onboarding, + ...stableParams + } = resolvedSearchParams || {}; + const searchParamsKey = JSON.stringify(stableParams); return ( - + {isCloudEnvironment && } { return (
- {/* ProviderTypeSelector */} - {/* Organizations filter */} - {/* Provider Groups filter */} - {/* Status filter */} - {/* Action buttons */}
diff --git a/ui/app/(prowler)/scans/page.test.ts b/ui/app/(prowler)/scans/page.test.ts new file mode 100644 index 0000000000..3ea7bcfa7c --- /dev/null +++ b/ui/app/(prowler)/scans/page.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("scans page onboarding", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + it("redirects the scan tour replay to add-provider when providers are missing or disconnected", () => { + expect(source).toContain('redirect("/providers?onboarding=add-provider")'); + expect(source).toContain( + 'resolvedSearchParams.onboarding === "view-first-scan"', + ); + }); + + it("passes the scan onboarding action to the page header when the tour can run", () => { + expect(source).toContain('flowId: "view-first-scan"'); + expect(source).toContain("onboardingAction={onboardingAction}"); + }); +}); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 62aba551ae..4bde757224 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,8 +1,10 @@ +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; +import { PageReady } from "@/components/onboarding"; import { getScanJobsTab, getScanJobsTabFilters, @@ -69,19 +71,45 @@ export default async function Scans({ const thereIsNoProviders = providers.length === 0; const thereIsNoProvidersConnected = !thereIsNoProviders && connectedProviders.length === 0; + const missingScanPrerequisite = + thereIsNoProviders || thereIsNoProvidersConnected; + + if ( + missingScanPrerequisite && + resolvedSearchParams.onboarding === "view-first-scan" + ) { + redirect("/providers?onboarding=add-provider"); + } const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); - const activeScanCount = - thereIsNoProviders || thereIsNoProvidersConnected - ? 0 - : await getActiveScanCount(resolvedSearchParams); + const activeScanCount = missingScanPrerequisite + ? 0 + : await getActiveScanCount(resolvedSearchParams); + const onboardingAction = missingScanPrerequisite + ? { + flowId: "view-first-scan", + fallbackFlowId: "add-provider", + useFallback: true, + } + : { flowId: "view-first-scan" }; return ( - - {thereIsNoProviders || thereIsNoProvidersConnected ? ( - + + {missingScanPrerequisite ? ( + <> + {/* The populated branch mounts inside ScansPageShell to + enable the navbar tour icon. The empty branch must mark the route + ready too, otherwise the icon (which falls back to the add-provider + flow here) stays hidden for users with no connected provider. */} + + + ) : ( = ({ }; const navigateToDetail = () => { - const formattedTitleForUrl = encodeURIComponent(title); - const path = `/compliance/${formattedTitleForUrl}`; - const params = new URLSearchParams(); - - params.set("complianceId", id); - params.set("version", version); - params.set("scanId", scanId); - - const regionFilter = searchParams.get("filter[region__in]"); - if (regionFilter) { - params.set("filter[region__in]", regionFilter); - } - - router.push(`${path}?${params.toString()}`); + router.push( + buildComplianceDetailPath({ + title, + complianceId: id, + version, + scanId, + regionFilter: searchParams.get("filter[region__in]"), + }), + ); }; return ( diff --git a/ui/components/compliance/compliance-overview-grid.tsx b/ui/components/compliance/compliance-overview-grid.tsx index 280465520a..b042ccc658 100644 --- a/ui/components/compliance/compliance-overview-grid.tsx +++ b/ui/components/compliance/compliance-overview-grid.tsx @@ -1,12 +1,26 @@ "use client"; -import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; import { ComplianceCard } from "@/components/compliance/compliance-card"; +import { OnboardingTrigger, PageReady } from "@/components/onboarding"; import { DataTableSearch } from "@/components/ui/table/data-table-search"; +import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url"; +import { getFlowById } from "@/lib/onboarding"; +import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { ScanEntity } from "@/types/scans"; +const viewComplianceFlow = getFlowById("view-compliance")!; + +// Module-level so the identity is stable: `configOverrides` is an effect dependency in +// `useDriverTour`, and a fresh object per keystroke would tear the tour down mid-typing. +const VIEW_COMPLIANCE_TOUR_CONFIG = { + // Last step opens the first card (see createViewComplianceTourStepHandlers). + doneBtnText: "Open Compliance", +}; + interface ComplianceOverviewGridProps { frameworks: ComplianceOverviewData[]; scanId: string; @@ -25,6 +39,8 @@ export const ComplianceOverviewGrid = ({ selectedScan, latestCisIds, }: ComplianceOverviewGridProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); const [searchTerm, setSearchTerm] = useState(""); const filteredFrameworks = frameworks.filter((compliance) => @@ -33,20 +49,54 @@ export const ComplianceOverviewGrid = ({ .includes(searchTerm.toLowerCase()), ); + const resetSearch = () => { + setSearchTerm(""); + return frameworks.length > 0; + }; + + const openFirstFramework = () => { + const first = frameworks[0]; + if (!first) return; + router.push( + buildComplianceDetailPath({ + title: first.attributes.framework, + complianceId: first.id, + version: first.attributes.version, + scanId, + regionFilter: searchParams.get("filter[region__in]"), + }), + ); + }; + return ( <> -
- + + + {/* Signals the navbar that this route's data has loaded (enables the replay icon). */} + +
+
+ +
{filteredFrameworks.length.toLocaleString()} Total Entries
- {filteredFrameworks.map((compliance) => { + {filteredFrameworks.map((compliance, index) => { const { attributes, id } = compliance; const { framework, @@ -55,9 +105,8 @@ export const ComplianceOverviewGrid = ({ total_requirements, } = attributes; - return ( + const card = ( ); + + // Anchor the tour to a single card, not the whole grid: highlighting the + // grid lit up the entire viewport and scrolled the page to the bottom. + return index === 0 ? ( +
+ {card} +
+ ) : ( +
+ {card} +
+ ); })}
diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 02a3aec62d..349769f3ad 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -95,7 +95,6 @@ export const FindingsFilterBatchControls = ({ const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; - // Custom filters for the expandable section. const customFilters = [ ...filterFindings .filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS) @@ -182,8 +181,6 @@ export const FindingsFilterBatchControls = ({ const showAppliedRow = appliedFilterChips.length > 0; const showPendingRow = hasChanges; - // Handler for removing a single chip: update the pending filter to remove that value. - // setPending handles both "filter[key]" and "key" formats internally. const handleChipRemove = (filterKey: string, value?: string) => { if (value === undefined) { setPending(filterKey, []); @@ -195,7 +192,6 @@ export const FindingsFilterBatchControls = ({ setPending(filterKey, nextValues); }; - // For the date picker, read from pendingFilters const pendingDateValues = pendingFilters["filter[inserted_at]"]; const pendingDateValue = pendingDateValues && pendingDateValues.length > 0 @@ -333,19 +329,21 @@ export const FindingsFilters = (props: FindingsFiltersProps) => { }); return ( - +
+ +
); }; diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 38887bf1fb..411f7a64f7 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -9,17 +9,47 @@ vi.mock("next/navigation", () => ({ refresh: vi.fn(), }), useSearchParams: () => new URLSearchParams(), + usePathname: () => "/findings", })); vi.mock("@/components/ui/table", () => ({ - DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => ( + DataTable: ({ + data, + toolbarRightContent, + getRowAttributes, + }: { + data?: Array<{ checkId?: string }>; + toolbarRightContent?: ReactNode; + getRowAttributes?: (row: { + index: number; + original: { checkId?: string }; + }) => Record; + }) => (
{toolbarRightContent}
10 Total Entries + + + {(data ?? []).map((original, index) => ( + + + + ))} + +
{original.checkId}
), })); +vi.mock("@/components/onboarding", () => ({ + OnboardingTrigger: () =>
, + PageReady: () =>
, +})); + vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({ CustomCheckboxMutedFindings: () => (