feat(ui:overview) overview findings by status and severity (#5925)

This commit is contained in:
Pablo Lara
2024-11-27 13:53:16 +01:00
committed by GitHub
parent 4a8150d613
commit 677e20a1a4
17 changed files with 754 additions and 293 deletions
+95 -1
View File
@@ -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;
}
};
+71 -34
View File
@@ -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
View File
@@ -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}
/>
</>
);
-76
View File
@@ -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>
);
};
-148
View File
@@ -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>
);
}
-2
View File
@@ -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>
);
};
@@ -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>
);
};
+4
View File
@@ -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"
+35
View File
@@ -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";