diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index 8d83ab7088..2095a9f32f 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -39,7 +39,7 @@ export const getProvidersOverview = async ({ const data = await response.json(); const parsedData = parseStringify(data); - revalidatePath("/providers-overview"); + revalidatePath("/"); return parsedData; } catch (error) { // eslint-disable-next-line no-console @@ -47,3 +47,97 @@ export const getProvidersOverview = async ({ return undefined; } }; + +export const getFindingsByStatus = async ({ + page = 1, + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + if (isNaN(Number(page)) || page < 1) redirect("/"); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/overviews/findings`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + // Handle multiple filters + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch findings severity: ${response.status}`); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + revalidatePath("/"); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching findings severity overview:", error); + return undefined; + } +}; + +export const getFindingsBySeverity = async ({ + page = 1, + query = "", + sort = "", + filters = {}, +}) => { + const session = await auth(); + + if (isNaN(Number(page)) || page < 1) redirect("/"); + + const keyServer = process.env.API_BASE_URL; + const url = new URL(`${keyServer}/overviews/findings_severity`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + // Handle multiple filters + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]") { + url.searchParams.append(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch findings severity: ${response.status}`); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + revalidatePath("/"); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching findings severity overview:", error); + return undefined; + } +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 38c9800467..1bc58ad741 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -16,44 +16,70 @@ export default async function Compliance({ }: { searchParams: SearchParamsProps; }) { - const scansData = await getScans({}); - const scanList = scansData?.data - .filter( - (scan: any) => - scan.attributes.state === "completed" && - scan.attributes.progress === 100, - ) - .map((scan: any) => ({ - id: scan.id, - name: scan.attributes.name || "Unnamed Scan", - state: scan.attributes.state, - progress: scan.attributes.progress, - })); + let scansData; + let scanList: { + id: string; + name: string; + state: string; + progress: number; + }[] = []; - const selectedScanId = searchParams.scanId || scanList[0]?.id; + try { + scansData = await getScans({}); + scanList = + scansData?.data + ?.filter( + (scan: any) => + scan.attributes.state === "completed" && + scan.attributes.progress === 100, + ) + .map((scan: any) => ({ + id: scan.id, + name: scan.attributes.name || "Unnamed Scan", + state: scan.attributes.state, + progress: scan.attributes.progress, + })) || []; + } catch (error) { + console.error("Error fetching scans data:", error); + } + + const selectedScanId = searchParams.scanId || scanList[0]?.id || null; + + // If there are no scans available, return a message + if (!selectedScanId) { + return ( +
+
No scans available to select.
+
+ ); + } // Fetch compliance data for regions - const compliancesData = await getCompliancesOverview({ - scanId: selectedScanId, - }); - - // Extract unique regions - const regions = compliancesData?.data - ? Array.from( - new Set( - compliancesData.data.map( - (compliance: ComplianceOverviewData) => - compliance.attributes.region as string, + let compliancesData; + let regions: string[] = []; + try { + compliancesData = await getCompliancesOverview({ + scanId: selectedScanId as string, + }); + regions = compliancesData?.data + ? Array.from( + new Set( + compliancesData.data.map( + (compliance: ComplianceOverviewData) => + compliance.attributes.region as string, + ), ), - ), - ) - : []; + ) + : []; + } catch (error) { + console.error("Error fetching compliance data:", error); + } return ( <>
- + }> @@ -68,14 +94,25 @@ const SSRComplianceGrid = async ({ searchParams: SearchParamsProps; }) => { const scanId = searchParams.scanId?.toString() || ""; - const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; // Fetch compliance data - const compliancesData = await getCompliancesOverview({ - scanId, - region: regionFilter, - }); + let compliancesData; + try { + compliancesData = await getCompliancesOverview({ + scanId, + region: regionFilter, + }); + } catch (error) { + console.error("Error fetching compliances overview:", error); + return ( +
+
+ Failed to load compliance data. Please try again later. +
+
+ ); + } // Check if the response contains no data if (!compliancesData || compliancesData?.data?.length === 0) { diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index df260ec8e3..fedfdf672c 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -2,10 +2,19 @@ import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; import { getFindings } from "@/actions/findings/findings"; -import { getProvidersOverview } from "@/actions/overview/overview"; import { + getFindingsBySeverity, + getFindingsByStatus, + getProvidersOverview, +} from "@/actions/overview/overview"; +import { FilterControls } from "@/components/filters"; +import { + FindingsBySeverityChart, + FindingsByStatusChart, LinkToFindings, ProvidersOverview, + SkeletonFindingsBySeverityChart, + SkeletonFindingsByStatusChart, SkeletonProvidersOverview, } from "@/components/overview"; import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date"; @@ -24,9 +33,9 @@ export default function Home({ <>
+
- {/* Providers Overview, Chart and New Findings Table */}
}> @@ -34,9 +43,18 @@ export default function Home({
- {/* Space for future chart */} -
- {/* Future chart */} +
+
+ }> + + +
+ +
+ }> + + +
@@ -44,7 +62,7 @@ export default function Home({ key={searchParamsKey} fallback={} > - +
@@ -65,34 +83,80 @@ const SSRProvidersOverview = async () => { ); }; -const SSRDataNewFindingsTable = async ({ +const SSRFindingsByStatus = async ({ searchParams, }: { - searchParams: SearchParamsProps; + searchParams: SearchParamsProps | undefined | null; }) => { - const page = parseInt(searchParams.page?.toString() || "1", 10); - const sort = searchParams.sort?.toString(); + const filters = searchParams + ? Object.fromEntries( + Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), + ), + ) + : {}; + + const findingsByStatus = await getFindingsByStatus({ filters }); + + return ( + <> +

Findings by Status

+ + + ); +}; + +const SSRFindingsBySeverity = async ({ + searchParams, +}: { + searchParams: SearchParamsProps | undefined | null; +}) => { + const filters = searchParams + ? Object.fromEntries( + Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), + ), + ) + : {}; + + const findingsBySeverity = await getFindingsBySeverity({ filters }); + + return ( + <> +

Findings by Severity

+ + + ); +}; + +const SSRDataNewFindingsTable = async () => { + // Temporarily disabled search params handling + // const page = parseInt(searchParams.page?.toString() || "1", 10); + // const sort = searchParams.sort?.toString(); + const page = 1; + const sort = undefined; // Extract all filter parameters - const filters = Object.fromEntries( - Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")), - ); + // const filters = Object.fromEntries( + // Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")), + // ); - const defaultFilters = - Object.keys(filters).length === 0 - ? { - "filter[severity]": "critical", - "filter[delta__in]": "new", - "filter[status__in]": "FAIL", - } - : {}; + // const defaultFilters = + // Object.keys(filters).length === 0 + // ? { + const defaultFilters = { + "filter[delta__in]": "new", + "filter[status__in]": "FAIL", + }; + // : {}; const finalFilters = { ...defaultFilters, - ...filters, + // ...filters, // Temporarily disabled additional filters } as Record; - const query = finalFilters["filter[search]"]; + // const query = finalFilters["filter[search]"]; + const query = undefined; const findingsData = await getFindings({ query, @@ -103,16 +167,18 @@ const SSRDataNewFindingsTable = async ({ return ( <> -
+

New failing findings to date

- +
+ +
); diff --git a/ui/components/charts/SeverityChart.tsx b/ui/components/charts/SeverityChart.tsx deleted file mode 100644 index 4c3060c8bd..0000000000 --- a/ui/components/charts/SeverityChart.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts"; - -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart/Chart"; - -const chartData = [ - { severity: "critical", findings: 32, fill: "var(--color-critical)" }, - { severity: "high", findings: 78, fill: "var(--color-high)" }, - { severity: "medium", findings: 117, fill: "var(--color-medium)" }, - { severity: "low", findings: 39, fill: "var(--color-low)" }, -]; - -const chartConfig = { - findings: { - label: "Findings", - }, - critical: { - label: "Critical", - color: "hsl(var(--chart-critical))", - }, - high: { - label: "High", - color: "hsl(var(--chart-fail))", - }, - medium: { - label: "Medium", - color: "hsl(var(--chart-medium))", - }, - low: { - label: "Low", - color: "hsl(var(--chart-low))", - }, -} satisfies ChartConfig; - -export const SeverityChart = () => { - return ( -
- - - - chartConfig[value as keyof typeof chartConfig]?.label - } - /> - - - - } - /> - - - - - - -
- ); -}; diff --git a/ui/components/charts/StatusChart.tsx b/ui/components/charts/StatusChart.tsx deleted file mode 100644 index d906db502e..0000000000 --- a/ui/components/charts/StatusChart.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { Chip, Divider, Spacer } from "@nextui-org/react"; -import { TrendingUp } from "lucide-react"; -import * as React from "react"; -import { Label, Pie, PieChart } from "recharts"; - -import { NotificationIcon, SuccessIcon } from "../icons"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "../ui"; - -const calculatePercent = ( - chartData: { findings: string; number: number; fill: string }[], -) => { - const total = chartData.reduce((sum, item) => sum + item.number, 0); - - return chartData.map((item) => ({ - ...item, - percent: Math.round((item.number / total) * 100) + "%", - })); -}; - -const chartData = [ - { - findings: "Success", - number: 436, - fill: "var(--color-success)", - }, - { findings: "Fail", number: 293, fill: "var(--color-fail)" }, -]; - -const updatedChartData = calculatePercent(chartData); - -const chartConfig = { - number: { - label: "Findings", - }, - success: { - label: "Success", - color: "hsl(var(--chart-success))", - }, - fail: { - label: "Fail", - color: "hsl(var(--chart-fail))", - }, -} satisfies ChartConfig; - -export function StatusChart() { - const totalVisitors = React.useMemo(() => { - return chartData.reduce((acc, curr) => acc + curr.number, 0); - }, []); - - return ( -
- - - } /> - - - - -
-
- } - color="success" - radius="lg" - size="md" - > - {chartData[0].number} - - - {updatedChartData[0].percent} - -
-
- No change from last scan -
- -
-
- } - color="danger" - radius="lg" - size="md" - > - {chartData[1].number} - - - {updatedChartData[1].percent} - -
-
- +2 findings from last scan -
-
-
-
- ); -} diff --git a/ui/components/charts/index.ts b/ui/components/charts/index.ts deleted file mode 100644 index b4d3debb45..0000000000 --- a/ui/components/charts/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./SeverityChart"; -export * from "./StatusChart"; diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx index 2a982c1781..21bb209282 100644 --- a/ui/components/filters/custom-date-picker.tsx +++ b/ui/components/filters/custom-date-picker.tsx @@ -60,6 +60,8 @@ export const CustomDatePicker = () => {
{ +export const CustomRegionSelection: React.FC = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Memoize selected keys based on the URL + const selectedKeys = useMemo(() => { + const params = searchParams.get("filter[regions]"); + return params ? params.split(",") : []; + }, [searchParams]); + + const applyRegionFilter = useCallback( + (values: string[]) => { + const params = new URLSearchParams(searchParams.toString()); + if (values.length > 0) { + params.set("filter[regions]", values.join(",")); + } else { + params.delete("filter[regions]"); + } + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams], + ); + return ( ); diff --git a/ui/components/filters/custom-search-input.tsx b/ui/components/filters/custom-search-input.tsx index f3d9808125..a5f4519877 100644 --- a/ui/components/filters/custom-search-input.tsx +++ b/ui/components/filters/custom-search-input.tsx @@ -39,8 +39,9 @@ export const CustomSearchInput: React.FC = () => { } onChange={(e) => { diff --git a/ui/components/filters/custom-select-provider.tsx b/ui/components/filters/custom-select-provider.tsx index d2a419bbbb..907e8be10b 100644 --- a/ui/components/filters/custom-select-provider.tsx +++ b/ui/components/filters/custom-select-provider.tsx @@ -66,7 +66,8 @@ export const CustomSelectProvider: React.FC = () => { items={dataInputsProvider} aria-label="Select a Provider" placeholder="Select a provider" - labelPlacement="outside" + label="Provider" + labelPlacement="inside" size="sm" onChange={(e) => { const value = e.target.value; diff --git a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx new file mode 100644 index 0000000000..7e590fb0d1 --- /dev/null +++ b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Card, CardBody } from "@nextui-org/react"; +import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts"; + +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart/Chart"; +import { FindingsSeverityOverview } from "@/types/components"; + +const chartConfig = { + critical: { + label: "Critical", + color: "hsl(var(--chart-critical))", + }, + high: { + label: "High", + color: "hsl(var(--chart-fail))", + }, + medium: { + label: "Medium", + color: "hsl(var(--chart-medium))", + }, + low: { + label: "Low", + color: "hsl(var(--chart-low))", + }, + informational: { + label: "Informational", + color: "hsl(var(--chart-informational))", + }, +} satisfies ChartConfig; + +export const FindingsBySeverityChart = ({ + findingsBySeverity, +}: { + findingsBySeverity: FindingsSeverityOverview; +}) => { + const defaultAttributes = { + critical: 0, + high: 0, + medium: 0, + low: 0, + informational: 0, + }; + + const attributes = findingsBySeverity?.data?.attributes || defaultAttributes; + + const chartData = Object.entries(attributes).map(([severity, findings]) => ({ + severity, + findings, + fill: chartConfig[severity as keyof typeof chartConfig]?.color, + })); + + return ( + + +
+ + + + chartConfig[value as keyof typeof chartConfig]?.label + } + /> + + + + } + /> + + + + + +
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx new file mode 100644 index 0000000000..e3eb51b49f --- /dev/null +++ b/ui/components/overview/findings-by-severity-chart/skeleton-findings-severity-chart.tsx @@ -0,0 +1,62 @@ +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +export const SkeletonFindingsBySeverityChart = () => { + return ( + + + +
+
+
+ +
+ {/* Critical */} +
+ +
+
+ +
+
+
+ {/* High */} +
+ +
+
+ +
+
+
+ {/* Medium */} +
+ +
+
+ +
+
+
+ {/* Low */} +
+ +
+
+ +
+
+
+ {/* Informational */} +
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx new file mode 100644 index 0000000000..0f6151ce96 --- /dev/null +++ b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Card, CardBody } from "@nextui-org/react"; +import { Chip } from "@nextui-org/react"; +import { TrendingUp } from "lucide-react"; +import React, { useMemo } from "react"; +import { Label, Pie, PieChart } from "recharts"; + +import { NotificationIcon, SuccessIcon } from "@/components/icons"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart/Chart"; + +const calculatePercent = ( + chartData: { findings: string; number: number; fill: string }[], +) => { + const total = chartData.reduce((sum, item) => sum + item.number, 0); + + return chartData.map((item) => ({ + ...item, + percent: total > 0 ? Math.round((item.number / total) * 100) + "%" : "0%", + })); +}; + +interface FindingsByStatusChartProps { + findingsByStatus: { + data: { + attributes: { + fail: number; + pass: number; + pass_new: number; + fail_new: number; + total: number; + }; + }; + }; +} + +const chartConfig = { + number: { + label: "Findings", + }, + success: { + label: "Success", + color: "hsl(var(--chart-success))", + }, + fail: { + label: "Fail", + color: "hsl(var(--chart-fail))", + }, +} satisfies ChartConfig; + +export const FindingsByStatusChart: React.FC = ({ + findingsByStatus, +}) => { + const { + fail = 0, + pass = 0, + pass_new = 0, + fail_new = 0, + } = findingsByStatus?.data?.attributes || {}; + const chartData = [ + { + findings: "Success", + number: pass, + fill: "var(--color-success)", + }, + { + findings: "Fail", + number: fail, + fill: "var(--color-fail)", + }, + ]; + + const updatedChartData = calculatePercent(chartData); + + const totalFindings = useMemo( + () => chartData.reduce((acc, curr) => acc + curr.number, 0), + [chartData], + ); + + const emptyChartData = [ + { + findings: "Empty", + number: 1, + fill: "hsl(var(--nextui-default-200))", + }, + ]; + + return ( + + +
+ + + } /> + 0 ? chartData : emptyChartData} + dataKey="number" + nameKey="findings" + innerRadius={40} + strokeWidth={35} + > + + + +
+
+
+ } + color="success" + radius="lg" + size="md" + > + {chartData[0].number} + + {updatedChartData[0].percent} +
+
+ {pass_new > 0 ? ( + <> + +{pass_new} pass findings from last day{" "} + + + ) : pass_new < 0 ? ( + <>{pass_new} pass findings from last day + ) : ( + "No change from last day" + )} +
+
+
+
+ } + color="danger" + radius="lg" + size="md" + > + {chartData[1].number} + + {updatedChartData[1].percent} +
+
+ +{fail_new} fail findings from last day{" "} + +
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx b/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx new file mode 100644 index 0000000000..f4b8a9d12b --- /dev/null +++ b/ui/components/overview/findings-by-status-chart/skeleton-findings-status-chart.tsx @@ -0,0 +1,54 @@ +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +export const SkeletonFindingsByStatusChart = () => { + return ( + + + +
+
+
+ +
+ {/* Circle Chart Skeleton */} + +
+
+ + {/* Text Details Skeleton */} +
+ {/* Pass Findings */} +
+
+ +
+
+ +
+
+
+ +
+
+
+ + {/* Fail Findings */} +
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+ ); +}; diff --git a/ui/components/overview/index.ts b/ui/components/overview/index.ts index ed098ab865..e2beebb034 100644 --- a/ui/components/overview/index.ts +++ b/ui/components/overview/index.ts @@ -1,4 +1,8 @@ export * from "./AttackSurface"; +export * from "./findings-by-severity-chart/findings-by-severity-chart"; +export * from "./findings-by-severity-chart/skeleton-findings-severity-chart"; +export * from "./findings-by-status-chart/findings-by-status-chart"; +export * from "./findings-by-status-chart/skeleton-findings-status-chart"; export * from "./new-findings-table/link-to-findings/link-to-findings"; export * from "./provider-overview/provider-overview"; export * from "./provider-overview/skeleton-provider-overview"; diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx index 2cd675147a..533b32cb01 100644 --- a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx @@ -7,7 +7,7 @@ export const LinkToFindings = () => { return (