mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ui:overview) overview findings by status and severity (#5925)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-default-500">No scans available to select.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<Header title="Compliance" icon="fluent-mdl2:compliance-audit" />
|
||||
<Spacer y={4} />
|
||||
<DataCompliance scans={scanList} regions={regions as string[]} />
|
||||
<DataCompliance scans={scanList} regions={regions} />
|
||||
<Spacer y={12} />
|
||||
<Suspense fallback={<ComplianceSkeletonGrid />}>
|
||||
<SSRComplianceGrid searchParams={searchParams} />
|
||||
@@ -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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-default-500">
|
||||
Failed to load compliance data. Please try again later.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the response contains no data
|
||||
if (!compliancesData || compliancesData?.data?.length === 0) {
|
||||
|
||||
+92
-26
@@ -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({
|
||||
<>
|
||||
<Header title="Scan Overview" icon="solar:pie-chart-2-outline" />
|
||||
<Spacer y={4} />
|
||||
<FilterControls providers regions date />
|
||||
<div className="min-h-screen">
|
||||
<div className="container mx-auto space-y-8 px-0 py-6">
|
||||
{/* Providers Overview, Chart and New Findings Table */}
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Suspense fallback={<SkeletonProvidersOverview />}>
|
||||
@@ -34,9 +43,18 @@ export default function Home({
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Space for future chart */}
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
{/* Future chart */}
|
||||
<div className="col-span-12 space-y-6 lg:col-span-4">
|
||||
<div>
|
||||
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
|
||||
<SSRFindingsByStatus searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
|
||||
<SSRFindingsBySeverity searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5">
|
||||
@@ -44,7 +62,7 @@ export default function Home({
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableNewFindings />}
|
||||
>
|
||||
<SSRDataNewFindingsTable searchParams={searchParams} />
|
||||
<SSRDataNewFindingsTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<>
|
||||
<h3 className="mb-4 text-sm font-bold">Findings by Status</h3>
|
||||
<FindingsByStatusChart findingsByStatus={findingsByStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h3 className="mb-4 text-sm font-bold">Findings by Severity</h3>
|
||||
<FindingsBySeverityChart findingsBySeverity={findingsBySeverity} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="relative flex items-start justify-between">
|
||||
<h3 className="mb-4 w-full text-sm font-bold">
|
||||
New failing findings to date
|
||||
</h3>
|
||||
<LinkToFindings />
|
||||
<div className="absolute -top-6 right-0">
|
||||
<LinkToFindings />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={ColumnNewFindingsToDate}
|
||||
data={findingsData?.data || []}
|
||||
metadata={findingsData?.meta}
|
||||
// metadata={findingsData?.meta}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="my-auto">
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart accessibilityLayer data={chartData} layout="vertical">
|
||||
<YAxis
|
||||
dataKey="severity"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
chartConfig[value as keyof typeof chartConfig]?.label
|
||||
}
|
||||
/>
|
||||
<XAxis dataKey="findings" type="number" hide>
|
||||
<LabelList position="insideTop" offset={12} fontSize={12} />
|
||||
</XAxis>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
/>
|
||||
|
||||
<Bar dataKey="findings" layout="vertical" radius={12}>
|
||||
<LabelList
|
||||
position="insideRight"
|
||||
offset={10}
|
||||
className="fill-foreground font-bold"
|
||||
fontSize={12}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="my-auto flex items-center justify-center self-center">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square min-w-[200px] md:min-h-[250px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="number"
|
||||
nameKey="findings"
|
||||
innerRadius={60}
|
||||
strokeWidth={50}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
radius={70}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
{totalVisitors.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-foreground"
|
||||
>
|
||||
Findings
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="mx-6 flex flex-col justify-center gap-2 text-small">
|
||||
<div className="flex space-x-4">
|
||||
<Chip
|
||||
className="h-5"
|
||||
variant="flat"
|
||||
startContent={<SuccessIcon size={18} />}
|
||||
color="success"
|
||||
radius="lg"
|
||||
size="md"
|
||||
>
|
||||
{chartData[0].number}
|
||||
</Chip>
|
||||
<Divider orientation="vertical" />
|
||||
<span>{updatedChartData[0].percent}</span>
|
||||
<Divider orientation="vertical" />
|
||||
</div>
|
||||
<div className="flex items-center font-light leading-none">
|
||||
No change from last scan
|
||||
</div>
|
||||
<Spacer y={4} />
|
||||
<div className="text-muted-foreground flex flex-col gap-2 leading-none">
|
||||
<div className="flex space-x-4">
|
||||
<Chip
|
||||
className="h-5"
|
||||
variant="flat"
|
||||
startContent={<NotificationIcon size={18} />}
|
||||
color="danger"
|
||||
radius="lg"
|
||||
size="md"
|
||||
>
|
||||
{chartData[1].number}
|
||||
</Chip>
|
||||
<Divider orientation="vertical" />
|
||||
<span>{updatedChartData[1].percent}</span>
|
||||
<Divider orientation="vertical" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 font-medium leading-none">
|
||||
+2 findings from last scan <TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./SeverityChart";
|
||||
export * from "./StatusChart";
|
||||
@@ -60,6 +60,8 @@ export const CustomDatePicker = () => {
|
||||
<div className="flex w-full flex-col md:gap-2">
|
||||
<DatePicker
|
||||
aria-label="Select a Date"
|
||||
label="Date"
|
||||
labelPlacement="inside"
|
||||
CalendarTopContent={
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@nextui-org/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
const regions = [
|
||||
{ key: "af-south-1", label: "AF South 1" },
|
||||
@@ -33,7 +36,29 @@ const regions = [
|
||||
{ key: "us-west-2", label: "US West 2" },
|
||||
];
|
||||
|
||||
export const CustomRegionSelection = () => {
|
||||
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 (
|
||||
<Select
|
||||
label="Region"
|
||||
@@ -42,9 +67,11 @@ export const CustomRegionSelection = () => {
|
||||
selectionMode="multiple"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) => applyRegionFilter(Array.from(keys))}
|
||||
>
|
||||
{regions.map((acc) => (
|
||||
<SelectItem key={acc.key}>{acc.label}</SelectItem>
|
||||
{regions.map((region) => (
|
||||
<SelectItem key={region.key}>{region.label}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
||||
@@ -39,8 +39,9 @@ export const CustomSearchInput: React.FC = () => {
|
||||
<Input
|
||||
variant="flat"
|
||||
aria-label="Search"
|
||||
label="Search"
|
||||
placeholder="Search..."
|
||||
labelPlacement="outside"
|
||||
labelPlacement="inside"
|
||||
value={searchQuery}
|
||||
startContent={<SearchIcon className="text-default-400" width={16} />}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<Card className="dark:bg-prowler-blue-400">
|
||||
<CardBody>
|
||||
<div className="my-auto">
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
barGap={2}
|
||||
height={200}
|
||||
margin={{ left: 50 }}
|
||||
width={500}
|
||||
>
|
||||
<YAxis
|
||||
dataKey="severity"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
tickMargin={20}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
chartConfig[value as keyof typeof chartConfig]?.label
|
||||
}
|
||||
/>
|
||||
<XAxis dataKey="findings" type="number" hide>
|
||||
<LabelList position="insideTop" offset={1} fontSize={12} />
|
||||
</XAxis>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="findings"
|
||||
layout="vertical"
|
||||
radius={12}
|
||||
barSize={20}
|
||||
>
|
||||
<LabelList
|
||||
position="insideRight"
|
||||
offset={10}
|
||||
className="fill-foreground font-bold"
|
||||
fontSize={12}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const SkeletonFindingsBySeverityChart = () => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/3 rounded-lg">
|
||||
<div className="h-6 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Critical */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-1/4 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
{/* High */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-2/4 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
{/* Medium */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-3/4 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
{/* Low */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-1/2 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
{/* Informational */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-1/6 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-4 w-12 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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<FindingsByStatusChartProps> = ({
|
||||
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 (
|
||||
<Card className="dark:bg-prowler-blue-400">
|
||||
<CardBody>
|
||||
<div className="flex items-center gap-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-square w-[150px] min-w-[150px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={totalFindings > 0 ? chartData : emptyChartData}
|
||||
dataKey="number"
|
||||
nameKey="findings"
|
||||
innerRadius={40}
|
||||
strokeWidth={35}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-xl font-bold"
|
||||
>
|
||||
{totalFindings > 0
|
||||
? totalFindings.toLocaleString()
|
||||
: "0"}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
className="fill-foreground text-xs"
|
||||
>
|
||||
Findings
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Chip
|
||||
className="h-5"
|
||||
variant="flat"
|
||||
startContent={<SuccessIcon size={18} />}
|
||||
color="success"
|
||||
radius="lg"
|
||||
size="md"
|
||||
>
|
||||
{chartData[0].number}
|
||||
</Chip>
|
||||
<span>{updatedChartData[0].percent}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs font-medium leading-none">
|
||||
{pass_new > 0 ? (
|
||||
<>
|
||||
+{pass_new} pass findings from last day{" "}
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</>
|
||||
) : pass_new < 0 ? (
|
||||
<>{pass_new} pass findings from last day</>
|
||||
) : (
|
||||
"No change from last day"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Chip
|
||||
className="h-5"
|
||||
variant="flat"
|
||||
startContent={<NotificationIcon size={18} />}
|
||||
color="danger"
|
||||
radius="lg"
|
||||
size="md"
|
||||
>
|
||||
{chartData[1].number}
|
||||
</Chip>
|
||||
<span>{updatedChartData[1].percent}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs font-medium leading-none">
|
||||
+{fail_new} fail findings from last day{" "}
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react";
|
||||
|
||||
export const SkeletonFindingsByStatusChart = () => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-1/4 rounded-lg">
|
||||
<div className="h-6 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Circle Chart Skeleton */}
|
||||
<Skeleton className="aspect-square h-[150px] w-[150px] rounded-full">
|
||||
<div className="h-[150px] w-[150px] bg-default-200"></div>
|
||||
</Skeleton>
|
||||
|
||||
{/* Text Details Skeleton */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Pass Findings */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-16 rounded-lg">
|
||||
<div className="h-5 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-5 w-10 rounded-lg">
|
||||
<div className="h-5 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-48 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
|
||||
{/* Fail Findings */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-5 w-16 rounded-lg">
|
||||
<div className="h-5 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="h-5 w-10 rounded-lg">
|
||||
<div className="h-5 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-48 rounded-lg">
|
||||
<div className="h-4 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -7,7 +7,7 @@ export const LinkToFindings = () => {
|
||||
return (
|
||||
<div className="mt-4 flex w-full items-center justify-end">
|
||||
<CustomButton
|
||||
asLink="/findings?filter[severity]=critical&filter[delta__in]=new&filter[status__in]=FAIL"
|
||||
asLink="/findings?filter[delta__in]=new&filter[status__in]=FAIL"
|
||||
ariaLabel="Go to Findings page"
|
||||
variant="solid"
|
||||
color="action"
|
||||
|
||||
@@ -26,6 +26,41 @@ export type NextUIColors =
|
||||
| "danger"
|
||||
| "default";
|
||||
|
||||
export interface FindingsByStatusData {
|
||||
data: {
|
||||
type: "findings-overview";
|
||||
id: string;
|
||||
attributes: {
|
||||
fail: number;
|
||||
pass: number;
|
||||
total: number;
|
||||
fail_new: number;
|
||||
pass_new: number;
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
meta: {
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FindingsSeverityOverview {
|
||||
data: {
|
||||
type: "findings-severity-overview";
|
||||
id: string;
|
||||
attributes: {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
informational: number;
|
||||
};
|
||||
};
|
||||
meta: {
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderOverviewProps {
|
||||
data: {
|
||||
type: "provider-overviews";
|
||||
|
||||
Reference in New Issue
Block a user