feat(ui): add AI agents banner to overview (#12074)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
This commit is contained in:
Rubén De la Torre Vico
2026-07-22 16:00:52 +02:00
committed by GitHub
parent ff45f46047
commit 7f0dc9b7da
9 changed files with 287 additions and 89 deletions
@@ -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(
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
);
// 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(
<LighthouseOverviewBanner
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
/>,
);
// 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(
<LighthouseOverviewBanner href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT} />,
);
// Then
const card = screen
.getByRole("link", { name: /Find and remediate what actually matters\./ })
.querySelector("[data-slot='card']");
expect(card).toHaveClass("isolate");
});
});
@@ -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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// 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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.SETTINGS}
/>,
);
// 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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>,
);
// 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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>,
);
// 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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// 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(
<>
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>
</>,
);
// 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(
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={LIGHTHOUSE_OVERVIEW_BANNER_HREF.CHAT}
/>,
);
// Then
const card = screen
.getByRole("link", { name: /Find and remediate what actually matters\./ })
.querySelector("[data-slot='card']");
expect(card).toHaveClass("isolate");
});
});
@@ -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: <LighthouseIcon className="size-5" />,
title: "Lighthouse AI",
description: "Find and remediate what actually matters.",
},
agents: {
icon: <Bot className="size-5" />,
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<HTMLDivElement>(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 (
<Link
href={href}
className="group focus-visible:ring-border-input-primary block rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: undefined)}
className="group focus-visible:ring-border-input-primary block h-full rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Card
variant="base"
padding="none"
// isolate: the content's internal z-10 (above the gradient layers)
// must not compete with the page's sticky header, which is also z-10.
className="group-hover:border-border-input-primary relative isolate overflow-hidden transition-colors"
className="group-hover:border-border-input-primary relative isolate h-full overflow-hidden transition-colors"
onMouseMove={handleMouseMove}
>
<svg className="hidden">
<defs>
<filter id="blurMe">
<filter id={blurFilterId}>
<feGaussianBlur
in="SourceGraphic"
stdDeviation="10"
@@ -92,33 +121,41 @@ export function LighthouseOverviewBanner({
<div
className={cn(
"pointer-events-none absolute inset-0 blur-lg",
isSafari ? "blur-2xl" : "[filter:url(#blurMe)_blur(40px)]",
"overview-banner-gradient pointer-events-none absolute inset-0 blur-lg",
variant === OVERVIEW_BANNER_VARIANT.AGENTS
? "overview-banner-gradient-agents"
: undefined,
isSafari ? "blur-2xl" : undefined,
)}
style={
isSafari
? undefined
: { filter: `url(#${blurFilterId}) blur(40px)` }
}
>
<div className="animate-first lighthouse-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
<div className="animate-first overview-banner-gradient-neutral absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:center_center] opacity-100 [mix-blend-mode:hard-light]" />
<div className="animate-second lighthouse-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
<div className="animate-second overview-banner-gradient-primary absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%-200px)] opacity-80 [mix-blend-mode:hard-light]" />
<div className="animate-third lighthouse-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
<div className="animate-third overview-banner-gradient-primary-hover absolute [top:calc(50%-60%)] [left:calc(50%-60%)] h-[120%] w-[120%] [transform-origin:calc(50%+200px)] opacity-70 [mix-blend-mode:hard-light]" />
<div
ref={interactiveRef}
className="lighthouse-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
className="overview-banner-gradient-primary-press absolute -top-1/2 -left-1/2 h-full w-full opacity-60 [mix-blend-mode:hard-light]"
/>
</div>
<CardContent className="relative z-10 flex min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<CardContent className="relative z-10 flex h-full min-w-0 items-center justify-between gap-4 px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
<LighthouseIcon className="size-5" />
{icon}
</span>
<div className="min-w-0">
<p className="text-text-neutral-primary text-sm font-medium">
Lighthouse AI
{title}
</p>
<p className="text-text-neutral-secondary text-sm">
Find and remediate what actually matters.
{description}
</p>
</div>
</div>
@@ -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];
+28
View File
@@ -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("<OverviewBanner");
const firstProviderFilterPosition = source.indexOf(
"<ProviderAccountSelectors",
);
// When
const bannersRenderBeforeFilters =
firstBannerPosition < firstProviderFilterPosition;
// Then
expect(firstBannerPosition).toBeGreaterThan(-1);
expect(firstProviderFilterPosition).toBeGreaterThan(-1);
expect(bannersRenderBeforeFilters).toBe(true);
});
});
+22 -7
View File
@@ -10,11 +10,13 @@ import {
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { DOCS_URLS } from "@/lib/external-urls";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { LighthouseOverviewBanner } from "./_overview/_components/lighthouse-overview-banner";
import { OverviewBanner } from "./_overview/_components/overview-banner";
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
@@ -58,17 +60,30 @@ export default async function Home({
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
{/* Agents banner shows everywhere; Lighthouse is Cloud-only, so on a
local server the agents banner is the only child and fills the row. */}
<div className="mb-6 flex flex-col gap-6 lg:flex-row">
{lighthouseBannerHref ? (
<div className="min-w-0 lg:flex-1">
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.LIGHTHOUSE}
href={lighthouseBannerHref}
/>
</div>
) : null}
<div className="min-w-0 lg:flex-1">
<OverviewBanner
variant={OVERVIEW_BANNER_VARIANT.AGENTS}
href={DOCS_URLS.AI_AGENTS}
/>
</div>
</div>
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
{lighthouseBannerHref ? (
<div className="mb-6">
<LighthouseOverviewBanner href={lighthouseBannerHref} />
</div>
) : null}
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
@@ -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
+1
View File
@@ -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.
+20 -8
View File
@@ -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;