diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx deleted file mode 100644 index b407396ce8..0000000000 --- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner"; - -import { LighthouseOverviewBanner } from "./lighthouse-overview-banner"; - -describe("LighthouseOverviewBanner", () => { - it("renders Toni copy and opens a prompted chat when connected", () => { - // Given / When - render( - , - ); - - // Then - const link = screen.getByRole("link", { - name: /Find and remediate what actually matters\./, - }); - expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT); - expect(link).toHaveTextContent("Lighthouse AI"); - expect(link).toHaveTextContent("Find and remediate what actually matters."); - }); - - it("links to Lighthouse settings when no connected configuration exists", () => { - // Given / When - render( - , - ); - - // Then - expect( - screen.getByRole("link", { - name: /Find and remediate what actually matters\./, - }), - ).toHaveAttribute("href", "/lighthouse/settings"); - }); - - it("isolates its stacking so content never paints over the sticky navbar", () => { - // Given / When: the banner's inner z-10 must stay scoped to the card — - // without isolation it ties the sticky header's z-10 and wins by DOM order - render( - , - ); - - // Then - const card = screen - .getByRole("link", { name: /Find and remediate what actually matters\./ }) - .querySelector("[data-slot='card']"); - expect(card).toHaveClass("isolate"); - }); -}); diff --git a/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx new file mode 100644 index 0000000000..3912aa1f53 --- /dev/null +++ b/ui/app/(prowler)/_overview/_components/overview-banner.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DOCS_URLS } from "@/lib/external-urls"; + +import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner"; +import { OVERVIEW_BANNER_VARIANT } from "../_lib/overview-banner"; + +import { OverviewBanner } from "./overview-banner"; + +describe("OverviewBanner", () => { + it("renders Toni copy and opens a prompted chat when connected", () => { + // Given / When + render( + , + ); + + // Then + const link = screen.getByRole("link", { + name: /Find and remediate what actually matters\./, + }); + expect(link).toHaveAttribute("href", LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT); + expect(link).toHaveTextContent("Lighthouse AI"); + expect(link).toHaveTextContent("Find and remediate what actually matters."); + }); + + it("links to Lighthouse settings when no connected configuration exists", () => { + // Given / When + render( + , + ); + + // Then + const link = screen.getByRole("link", { + name: /Find and remediate what actually matters\./, + }); + expect(link).toHaveAttribute("href", "/lighthouse/settings"); + // In-app hrefs stay in the current tab + expect(link).not.toHaveAttribute("target"); + }); + + it("opens the AI agents docs in a new tab", () => { + // Given / When + render( + , + ); + + // Then + const link = screen.getByRole("link", { + name: /Connect all your agents to Prowler Cloud/, + }); + expect(link).toHaveAttribute("href", DOCS_URLS.AI_AGENTS); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(link).toHaveTextContent( + "Turn your favorite agent into a Cloud Security Expert.", + ); + }); + + it("uses the purple agents palette with the same animated layers", () => { + // Given / When + render( + , + ); + + // Then + const link = screen.getByRole("link", { + name: /Connect all your agents to Prowler Cloud/, + }); + const gradient = link.querySelector(".overview-banner-gradient"); + expect(gradient).toHaveClass("overview-banner-gradient-agents"); + expect(gradient?.querySelector(".animate-first")).toBeInTheDocument(); + expect(gradient?.querySelector(".animate-second")).toBeInTheDocument(); + expect(gradient?.querySelector(".animate-third")).toBeInTheDocument(); + expect( + gradient?.querySelector(".overview-banner-gradient-primary-press"), + ).toBeInTheDocument(); + }); + + it("keeps the Lighthouse banner on the default green palette", () => { + // Given / When + render( + , + ); + + // Then + const link = screen.getByRole("link", { + name: /Find and remediate what actually matters\./, + }); + const gradient = link.querySelector(".overview-banner-gradient"); + expect(gradient).not.toHaveClass("overview-banner-gradient-agents"); + }); + + it("scopes the blur filter id per instance so stacked banners keep their gradient", () => { + // Given / When: url(#id) resolves against the FIRST match in the document, + // so two banners sharing one id would both resolve to the same filter + const { container } = render( + <> + + + , + ); + + // Then + const filterIds = Array.from( + container.querySelectorAll("filter"), + (filter) => filter.id, + ); + expect(filterIds).toHaveLength(2); + expect(new Set(filterIds).size).toBe(2); + }); + + it("isolates its stacking so content never paints over the sticky navbar", () => { + // Given / When: the banner's inner z-10 must stay scoped to the card — + // without isolation it ties the sticky header's z-10 and wins by DOM order + render( + , + ); + + // Then + const card = screen + .getByRole("link", { name: /Find and remediate what actually matters\./ }) + .querySelector("[data-slot='card']"); + expect(card).toHaveClass("isolate"); + }); +}); diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx b/ui/app/(prowler)/_overview/_components/overview-banner.tsx similarity index 50% rename from ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx rename to ui/app/(prowler)/_overview/_components/overview-banner.tsx index 8bd8dea414..791685d35a 100644 --- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.tsx +++ b/ui/app/(prowler)/_overview/_components/overview-banner.tsx @@ -1,29 +1,55 @@ "use client"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Bot } from "lucide-react"; import Link from "next/link"; -import { useRef, useState } from "react"; +import { type ReactNode, useId, useRef, useState } from "react"; import { LighthouseIcon } from "@/components/icons/Icons"; import { Card, CardContent } from "@/components/shadcn"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { cn } from "@/lib/utils"; -import type { LighthouseOverviewBannerHref } from "../_lib/lighthouse-banner"; +import { + OVERVIEW_BANNER_VARIANT, + type OverviewBannerVariant, +} from "../_lib/overview-banner"; -interface LighthouseOverviewBannerProps { - href: LighthouseOverviewBannerHref; +// Content lives here rather than at the call site so the icons stay inside the +// client boundary — LighthouseIcon uses useId and cannot render on the server. +const OVERVIEW_BANNER_CONTENT = { + lighthouse: { + icon: , + title: "Lighthouse AI", + description: "Find and remediate what actually matters.", + }, + agents: { + icon: , + title: "Connect all your agents to Prowler Cloud", + description: "Turn your favorite agent into a Cloud Security Expert.", + }, +} as const satisfies Record< + OverviewBannerVariant, + { icon: ReactNode; title: string; description: string } +>; + +interface OverviewBannerProps { + variant: OverviewBannerVariant; + href: string; } -export function LighthouseOverviewBanner({ - href, -}: LighthouseOverviewBannerProps) { +export function OverviewBanner({ variant, href }: OverviewBannerProps) { + const { icon, title, description } = OVERVIEW_BANNER_CONTENT[variant]; + // Absolute hrefs leave the app, so they open in a new tab. + const isExternal = href.startsWith("http"); const interactiveRef = useRef(null); const curXRef = useRef(0); const curYRef = useRef(0); const tgXRef = useRef(0); const tgYRef = useRef(0); const [isSafari, setIsSafari] = useState(false); + // Several banners render per page and url(#id) resolves against the FIRST + // matching id in the document, so the filter id must be per-instance. + const blurFilterId = `overview-banner-blur-${useId().replace(/[«»:]/g, "")}`; useMountEffect(() => { setIsSafari(/^((?!chrome|android).)*safari/i.test(navigator.userAgent)); @@ -61,19 +87,22 @@ export function LighthouseOverviewBanner({ return ( - + -
+
-
+
-
+
- +
- + {icon}

- Lighthouse AI + {title}

- Find and remediate what actually matters. + {description}

diff --git a/ui/app/(prowler)/_overview/_lib/overview-banner.ts b/ui/app/(prowler)/_overview/_lib/overview-banner.ts new file mode 100644 index 0000000000..9d75db6ec2 --- /dev/null +++ b/ui/app/(prowler)/_overview/_lib/overview-banner.ts @@ -0,0 +1,7 @@ +export const OVERVIEW_BANNER_VARIANT = { + LIGHTHOUSE: "lighthouse", + AGENTS: "agents", +} as const; + +export type OverviewBannerVariant = + (typeof OVERVIEW_BANNER_VARIANT)[keyof typeof OVERVIEW_BANNER_VARIANT]; diff --git a/ui/app/(prowler)/page.test.tsx b/ui/app/(prowler)/page.test.tsx new file mode 100644 index 0000000000..78ddc4c550 --- /dev/null +++ b/ui/app/(prowler)/page.test.tsx @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("Overview page", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("renders the overview banners before the provider filters", () => { + // Given + const firstBannerPosition = source.indexOf(" + {/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a + local server the agents banner is the only child and fills the row. */} +
+ {lighthouseBannerHref ? ( +
+ +
+ ) : null} +
+ +
+
+
- {lighthouseBannerHref ? ( -
- -
- ) : null} -
}> diff --git a/ui/changelog.d/overview-agents-banner.added.md b/ui/changelog.d/overview-agents-banner.added.md new file mode 100644 index 0000000000..9bd386d4e9 --- /dev/null +++ b/ui/changelog.d/overview-agents-banner.added.md @@ -0,0 +1 @@ +Overview banner linking to the AI agents documentation, shown next to the Lighthouse AI banner in Cloud and full width on self-hosted deployments diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 0ae17384a9..e4b0a1573e 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -15,6 +15,7 @@ export const DOCS_URLS = { "https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration", ATTACK_PATHS_CUSTOM_QUERIES: "https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries", + AI_AGENTS: "https://docs.prowler.com/user-guide/ai-agents/", } as const; // CloudFormation template URL for the ProwlerScan role. diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 2b4b4abfc6..abcd7d1c16 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -581,8 +581,20 @@ bottom: 3rem !important; } - /* Lighthouse overview banner animated gradient layers */ - .lighthouse-banner-gradient-neutral { + /* Overview banner animated gradient layers */ + .overview-banner-gradient { + --overview-banner-gradient-primary: var(--bg-button-primary); + --overview-banner-gradient-primary-hover: var(--bg-button-primary-hover); + --overview-banner-gradient-primary-press: var(--bg-button-primary-press); + } + + .overview-banner-gradient-agents { + --overview-banner-gradient-primary: var(--color-violet-400); + --overview-banner-gradient-primary-hover: var(--color-fuchsia-300); + --overview-banner-gradient-primary-press: var(--color-indigo-500); + } + + .overview-banner-gradient-neutral { background: radial-gradient( circle at center, var(--bg-neutral-tertiary) 0, @@ -591,28 +603,28 @@ no-repeat; } - .lighthouse-banner-gradient-primary { + .overview-banner-gradient-primary { background: radial-gradient( circle at center, - var(--bg-button-primary) 0, + var(--overview-banner-gradient-primary) 0, transparent 50% ) no-repeat; } - .lighthouse-banner-gradient-primary-hover { + .overview-banner-gradient-primary-hover { background: radial-gradient( circle at center, - var(--bg-button-primary-hover) 0, + var(--overview-banner-gradient-primary-hover) 0, transparent 50% ) no-repeat; } - .lighthouse-banner-gradient-primary-press { + .overview-banner-gradient-primary-press { background: radial-gradient( circle at center, - var(--bg-button-primary-press) 0, + var(--overview-banner-gradient-primary-press) 0, transparent 50% ) no-repeat;