chore(ui): merge master into expired-session fix

This commit is contained in:
Alan Buscaglia committed 2026-07-28 10:41:50 +02:00
commit ecfea481ef
568 files changed
+23697 -2930

No files matched your search

+7 -3
View File
@@ -1,6 +1,10 @@
import { AuthForm } from "@/components/auth/oss";
import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper";
import { isGoogleOAuthEnabled } from "@/lib/helper";
import {
getAuthUrl,
isGithubOAuthEnabled,
isGoogleOAuthEnabled,
} from "@/lib/helper";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
const SignUp = async ({
@@ -13,7 +17,7 @@ const SignUp = async ({
typeof resolvedSearchParams?.invitation_token === "string"
? resolvedSearchParams.invitation_token
: null;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const GOOGLE_AUTH_URL = getAuthUrl("google");
const GITHUB_AUTH_URL = getAuthUrl("github");
@@ -1,52 +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];
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { AttackSurface } from "./_components/attack-surface";
export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => {
@@ -13,6 +13,7 @@ import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
@@ -7,6 +7,7 @@ import {
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import { RiskRadarViewClient } from "./risk-radar-view-client";
export async function RiskRadarViewSSR({
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ResourcesInventory } from "./_components/resources-inventory";
export const ResourcesInventorySSR = async ({
@@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { RiskSeverityChart } from "./_components/risk-severity-chart";
export const RiskSeverityChartSSR = async ({
@@ -15,6 +15,7 @@ import {
} from "@/types/severities";
import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants";
import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { FindingSeverityOverTime } from "./_components/finding-severity-over-time";
import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton";
import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants";
@@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ThreatScore } from "./_components/threat-score";
export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => {
@@ -5,6 +5,7 @@ import {
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
export const ComplianceWatchlistSSR = async ({
@@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview";
import { pickFilterParams } from "../_lib/filter-params";
import { SSRComponentProps } from "../_types";
import { ServiceWatchlist } from "./_components/service-watchlist";
export const ServiceWatchlistSSR = async ({
@@ -28,6 +28,7 @@ vi.mock("@/lib/server-actions-helper", () => ({
}));
import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types";
import {
createAlert,
deleteAlert,
@@ -15,12 +15,10 @@ import {
ALERT_TRIGGER_KINDS,
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Button, useToast } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
import type { MetaDataProps } from "@/types";
import type { ScanEntity } from "@/types";
import type { MetaDataProps, ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { toAlertPayload } from "../_lib/alert-adapter";
@@ -29,6 +27,7 @@ import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { AlertFormModal } from "./alert-form-modal";
import { AlertsEmptyState } from "./alerts-empty-state";
import { AlertsTable } from "./alerts-table";
@@ -28,8 +28,9 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
ToastAction,
useToast,
} from "@/components/shadcn";
import { ToastAction, useToast } from "@/components/shadcn";
import { useCloudUpgradeStore } from "@/store";
import type { ScanEntity } from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
@@ -31,9 +31,11 @@ import {
getNodeColor,
getPathEdges,
GRAPH_EDGE_HIGHLIGHT_COLOR,
isProwlerFindingNode,
resolveHiddenFindingIds,
} from "../../_lib";
import { isFindingNode, layoutWithDagre } from "../../_lib/layout";
import { layoutWithDagre } from "../../_lib/layout";
import { FindingNode } from "./nodes/finding-node";
import { InternetNode } from "./nodes/internet-node";
import { ResourceNode } from "./nodes/resource-node";
@@ -387,7 +389,7 @@ const GraphCanvas = ({
const findingToResources = new Map<string, Set<string>>();
nodes.forEach((n) => {
if (isFindingNode(n.labels)) findingNodeIds.add(n.id);
if (isProwlerFindingNode(n.labels)) findingNodeIds.add(n.id);
});
const resourcesWithFindings = new Set<string>();
@@ -459,7 +461,7 @@ const GraphCanvas = ({
hidden: hiddenFindingIds.has(node.id),
className: cn(
node.className,
isFindingNode(node.data.graphNode.labels) ||
isProwlerFindingNode(node.data.graphNode.labels) ||
resourcesWithFindings.has(node.id)
? "cursor-pointer"
: "cursor-default",
@@ -23,6 +23,7 @@ import {
GRAPH_NODE_COLORS,
} from "../../_lib/graph-colors";
import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
import { isProwlerFindingNode } from "../../_lib/node-types";
import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
const LEGEND_PREVIEW = {
@@ -197,7 +198,7 @@ const edgeItems: LegendEdgeItem[] = [
];
const isFindingNode = (node: GraphNode): boolean =>
node.labels.some((label) => label.toLowerCase().includes("finding"));
isProwlerFindingNode(node.labels);
const getGraphEdges = (
data: AttackPathGraphData,
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
interface FindingNodeData {
@@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
interface InternetNodeData {
@@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
interface ResourceNodeData {
@@ -50,6 +50,15 @@ const resourceNode: GraphNode = {
},
};
const guardDutyNode: GraphNode = {
id: "guard-duty-node-id",
labels: ["GuardDutyFinding"],
properties: {
id: "guard-duty-123",
title: "Port probe",
},
};
describe("NodeDetailPanel", () => {
it("renders the view finding button only for finding nodes", () => {
const { rerender } = render(<NodeDetailPanel node={findingNode} />);
@@ -65,6 +74,17 @@ describe("NodeDetailPanel", () => {
).not.toBeInTheDocument();
});
it("does not render the view finding button for cloud-provider finding resources", () => {
// Given/When
render(<NodeDetailPanel node={guardDutyNode} />);
// Then
expect(
screen.queryByRole("button", { name: /view finding/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Node findings")).toBeInTheDocument();
});
it("calls onViewFinding with the node finding id", async () => {
const user = userEvent.setup();
const onViewFinding = vi.fn();
@@ -11,6 +11,8 @@ import {
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { GraphNode } from "@/types/attack-paths";
import { isProwlerFindingNode } from "../../_lib";
import { NodeFindings } from "./node-findings";
import { NodeOverview } from "./node-overview";
import { NodeResources } from "./node-resources";
@@ -37,9 +39,7 @@ export const NodeDetailContent = ({
onViewFinding?: (findingId: string) => void;
viewFindingLoading?: boolean;
}) => {
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-6">
@@ -105,9 +105,7 @@ export const NodeDetailPanel = ({
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isProwlerFinding = node ? isProwlerFindingNode(node.labels) : false;
const findingId = node ? String(node.properties?.id || node.id) : "";
return (
@@ -5,7 +5,7 @@ import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabels } from "../../_lib";
import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
interface NodeOverviewProps {
node: GraphNode;
@@ -25,9 +25,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
return String(value);
};
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
return (
<div className="flex flex-col gap-4">
@@ -22,6 +22,7 @@ import {
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
import { isProwlerFindingNode } from "./node-types";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -67,8 +68,7 @@ const downloadDataUrl = (dataUrl: string, filename: string) => {
document.body.removeChild(link);
};
const isFindingNode = (labels: string[]) =>
labels.some((label) => label.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => {
if (graphData.edges?.length) return graphData.edges;
@@ -1,3 +1,5 @@
import { isProwlerFindingNode } from "./node-types";
/**
* Color constants for attack path graph visualization
* Colors chosen to work well in both light and dark themes
@@ -67,7 +69,7 @@ export const getNodeColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
@@ -100,7 +102,7 @@ export const getNodeBorderColor = (
labels: string[],
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
const isFinding = isProwlerFindingNode(labels);
if (isFinding && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
@@ -4,6 +4,8 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
import { isProwlerFindingNode } from "./node-types";
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
@@ -103,11 +105,11 @@ export const computeFilteredSubgraph = (
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const sourceIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.source) ?? [],
);
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
const targetIsFinding = isProwlerFindingNode(
nodeLabelMap.get(edge.target) ?? [],
);
// Include findings connected to the selected node
@@ -18,6 +18,7 @@ export {
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
export {
NODE_CATEGORY,
type NodeCategory,
@@ -17,6 +17,18 @@ const resourceNode: GraphNode = {
properties: { name: "bucket-1" },
};
const guardDutyNode: GraphNode = {
id: "guard-duty-1",
labels: ["GuardDutyFinding"],
properties: { title: "Port probe", severity: "high" },
};
const inspectorNode: GraphNode = {
id: "inspector-1",
labels: ["AWSInspectorFinding"],
properties: { title: "Package vulnerability", severity: "high" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
@@ -54,6 +66,42 @@ describe("layoutWithDagre", () => {
});
});
it("treats cloud-provider finding resources as resource nodes", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, guardDutyNode, inspectorNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")?.type).toBe("finding");
expect(byId.get("guard-duty-1")).toMatchObject({
type: "resource",
width: 136,
height: 124,
});
expect(byId.get("inspector-1")?.type).toBe("resource");
});
it("does not animate edges that only touch cloud-provider finding resources", () => {
const { rfEdges } = layoutWithDagre(
[resourceNode, guardDutyNode],
[
{
id: "e1",
source: "guard-duty-1",
target: "resource-1",
type: "AFFECTS",
},
],
);
expect(rfEdges[0]).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
@@ -13,6 +13,7 @@ import {
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { isProwlerFindingNode } from "./node-types";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -34,8 +35,7 @@ const NODE_TYPE = {
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE];
export const isFindingNode = (labels: string[]): boolean =>
labels.some((l) => l.toLowerCase().includes("finding"));
const isFindingNode = isProwlerFindingNode;
const getNodeType = (labels: string[]): NodeType => {
if (isFindingNode(labels)) return NODE_TYPE.FINDING;
@@ -0,0 +1,8 @@
const normalizeNodeLabel = (label: string): string =>
label.toLowerCase().replace(/[^a-z0-9]/g, "");
export const isProwlerFindingLabel = (label: string): boolean =>
normalizeNodeLabel(label) === "prowlerfinding";
export const isProwlerFindingNode = (labels: string[]): boolean =>
labels.some(isProwlerFindingLabel);
@@ -193,6 +193,28 @@ describe("resolveNodeVisual", () => {
});
});
it("should resolve cloud-provider finding resources as non-finding nodes", () => {
// Given
const guardDutyNode = buildNode(["GuardDutyFinding"], {
title: "Port probe",
severity: "high",
});
const inspectorNode = buildNode(["AWSInspectorFinding"], {
title: "Package vulnerability",
severity: "high",
});
// When
const guardDutyVisual = resolveNodeVisual(guardDutyNode);
const inspectorVisual = resolveNodeVisual(inspectorNode);
// Then
expect(guardDutyVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(guardDutyVisual.description).toBe("Guard Duty Finding");
expect(inspectorVisual.category).not.toBe(NODE_CATEGORY.FINDING);
expect(inspectorVisual.description).toBe("Aws Inspector Finding");
});
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
@@ -49,6 +49,7 @@ import {
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
import { isProwlerFindingLabel } from "./node-types";
export const NODE_CATEGORY = {
FINDING: "finding",
@@ -396,8 +397,7 @@ const normalizeLabel = (label: string): string =>
const isKnownNodeLabel = (label: string): label is KnownNodeLabel =>
label in KNOWN_NODE_VISUALS;
const isFindingLabel = (label: string): boolean =>
normalizeLabel(label).includes("finding");
const isFindingLabel = isProwlerFindingLabel;
const isInternetLabel = (label: string): boolean =>
normalizeLabel(label) === "internet";
@@ -35,8 +35,8 @@ vi.mock("@/actions/findings", async () => {
});
import { useGraphStore } from "./_hooks/use-graph-state";
import { getPathEdges } from "./_lib";
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
import { getPathEdges, isProwlerFindingNode } from "./_lib";
import { layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
@@ -268,7 +268,7 @@ describe("running a query", () => {
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
(node) => !isFindingNode(node.labels),
(node) => !isProwlerFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
@@ -477,7 +477,7 @@ describe("exploring the graph", () => {
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
.filter((node) => isFindingNode(node.labels))
.filter((node) => isProwlerFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
@@ -7,6 +7,7 @@
import { vi } from "vitest";
import { userEvent } from "vitest/browser";
import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness {
@@ -458,9 +459,7 @@ export class AttackPathPageHarness {
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -528,9 +527,7 @@ export class AttackPathPageHarness {
async expandAllFindings(): Promise<void> {
const findingIds = new Set(
(this.fixture.queryResult?.nodes ?? [])
.filter((n) =>
n.labels.some((l) => l.toLowerCase().includes("finding")),
)
.filter((n) => isProwlerFindingNode(n.labels))
.map((n) => n.id),
);
const resourceWithFindingIds = new Set<string>();
@@ -28,13 +28,13 @@ import {
import { StatusAlert } from "@/components/shared/status-alert";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
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,
@@ -60,7 +60,7 @@ 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";
import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
@@ -325,9 +325,7 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
const isFinding = isProwlerFindingNode(node.labels);
if (isFinding) {
if (findingNavigationInFlightRef.current) {
@@ -347,9 +345,7 @@ export default function AttackPathsPage() {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
return otherNode ? isProwlerFindingNode(otherNode.labels) : false;
});
if (hasFindings) {
@@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { COMPLIANCE_TAB } from "../_types";
import { CompliancePageTabs } from "./compliance-page-tabs";
import { getComplianceTab } from "./compliance-page-tabs.shared";
@@ -29,6 +29,7 @@ import {
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
@@ -11,6 +11,7 @@ import {
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS,
CROSS_PROVIDER_OVERVIEW_TYPE,
} from "../_types";
import { CrossProviderOverview } from "./cross-provider-overview";
vi.mock("../_actions/cross-provider", () => ({
@@ -15,6 +15,7 @@ import {
} from "../_lib/cross-provider-frameworks";
import type { CrossProviderFrameworkSummary } from "../_types";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderErrorAlert } from "./cross-provider-error-alert";
import type {
CrossProviderAccountOption,
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import type { CheckProviderTypesMap, Requirement } from "@/types/compliance";
import type { CrossProviderRequirementExtras } from "../_types";
import { CrossProviderRequirementContent } from "./cross-provider-requirement-content";
const { clientAccordionContentMock } = vi.hoisted(() => ({
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderBreakdownEntry } from "../_types";
import { ProviderCoverageCard } from "./provider-coverage-card";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
+14
View File
@@ -24,6 +24,7 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
@@ -68,6 +69,13 @@ export default async function Findings({
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -110,6 +118,7 @@ export default async function Findings({
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
@@ -145,6 +154,10 @@ const SSRDataTable = async ({
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
: expandedCheckIdParam;
const { encodedSort } = extractSortAndKey(searchParams);
const hasHistoricalData = hasDateOrScanFilter(filters);
@@ -178,6 +191,7 @@ const SSRDataTable = async ({
metadata={findingGroupsData?.meta}
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
/>
</>
);
@@ -1,4 +1,3 @@
import React from "react";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
@@ -29,6 +29,7 @@ import {
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ProviderIcon } from "../config/provider-icon";
import { ChatComposerPanel } from "./composer";
import { ChatEmptyState } from "./empty-state";
import { useLighthouseChatStore } from "./lighthouse-chat-store-provider";
@@ -170,11 +170,12 @@ describe("LighthousePanelChat", () => {
// When
render(<LighthousePanelChat />);
// Then: composer is live and the empty state lists recent chats
// Then: composer is live and the empty state lists recent chats.
// Sessions load on a later render than the composer, so await them.
expect(
await screen.findByRole("textbox", { name: "Message" }),
).toBeInTheDocument();
expect(screen.getByText("Recent chats")).toBeInTheDocument();
expect(await screen.findByText("Recent chats")).toBeInTheDocument();
expect(
await screen.findByText("Counting critical findings"),
).toBeInTheDocument();
@@ -42,6 +42,7 @@ import {
LIGHTHOUSE_CHAT_SURFACE,
LighthouseV2ChatView,
} from "../chat/lighthouse-v2-chat-view";
import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton";
const PANEL_CHAT_STATUS = {
@@ -6,6 +6,7 @@ import { Suspense } from "react";
import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider";
import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
export const BEDROCK_AUTH_MODES = {
@@ -44,7 +45,7 @@ function ConnectContent() {
}
export default function ConnectLLMProviderPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -12,8 +12,7 @@ import {
} from "@/actions/lighthouse-v1/lighthouse";
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
import { Button } from "@/components/shadcn";
import { NavigationHeader } from "@/components/shadcn";
import { Button, NavigationHeader } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
@@ -5,6 +5,7 @@ import { Suspense } from "react";
import { SelectModel } from "@/components/lighthouse-v1/select-model";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
function SelectModelContent() {
@@ -27,7 +28,7 @@ function SelectModelContent() {
}
export default function SelectModelPage() {
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
if (isCloud()) {
redirect(LIGHTHOUSE_ROUTE.SETTINGS);
}
@@ -15,8 +15,8 @@ import {
FieldError,
Skeleton,
Textarea,
useToast,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { fontMono } from "@/config/fonts";
@@ -4,8 +4,7 @@ import { useState } from "react";
import { toggleMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Switch } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Switch, useToast } from "@/components/shadcn";
interface MuteRuleEnabledToggleProps {
muteRule: MuteRuleData;
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { type MuteRuleTableData } from "./mute-rule-target-previews";
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
@@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
const longMuteRule: MuteRuleTableData = {
type: "mute-rules",
id: "mute-rule-1",
+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} />
@@ -17,8 +17,8 @@ import {
FieldLabel,
Input,
Textarea,
useToast,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DOCS_URLS } from "@/lib/external-urls";
@@ -10,8 +10,7 @@ import {
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { Button, Card } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Button, Card, useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { DataTable } from "@/components/shadcn/table";
+2 -2
View File
@@ -11,6 +11,8 @@ import {
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import {
appendPendingScheduleRowsToPage,
buildScheduledTabRows,
@@ -20,8 +22,6 @@ import {
getScanJobsUserFilters,
pickScheduleProviderFilters,
} from "@/components/scans/scans.utils";
import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/shadcn/content-layout";