mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
feat(ui): Add resources view as inventory
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./resources";
|
||||
@@ -0,0 +1,101 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
apiBaseUrl,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
parseStringify,
|
||||
} from "@/lib";
|
||||
|
||||
export const getResources = async ({
|
||||
page = 1,
|
||||
query = "",
|
||||
sort = "",
|
||||
filters = {},
|
||||
pageSize = 10,
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1)
|
||||
redirect("resources?include=findings,provider");
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/resources?include=findings,provider`);
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
if (query) url.searchParams.append("filter[search]", query);
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
const resources = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await resources.json();
|
||||
const parsedData = parseStringify(data);
|
||||
|
||||
revalidatePath("/resources");
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching resources:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getResourceById = async (resourceId: string) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/resources/${resourceId}?fields[resources]=provider,region&include=findings,provider`,
|
||||
);
|
||||
|
||||
try {
|
||||
const resource = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
const data = await resource.json();
|
||||
const parsedData = parseStringify(data);
|
||||
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getResourceFields = async (fields: string, filters = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/resources`);
|
||||
|
||||
url.searchParams.append("fields[resources]", fields);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
const resource = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
const data = await resource.json();
|
||||
const parsedData = parseStringify(data);
|
||||
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -257,3 +257,42 @@ export const getExportsZip = async (scanId: string) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getScansByFields = async (
|
||||
fields: string = "state",
|
||||
filters = {},
|
||||
) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/scans`);
|
||||
|
||||
// Request only the necessary fields to optimize the response
|
||||
url.searchParams.append("fields[scans]", fields);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData?.message || "Failed to fetch scans by state");
|
||||
} catch {
|
||||
throw new Error("Failed to fetch scans by state");
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return parseStringify(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching scans by state:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Spacer } from "@nextui-org/react";
|
||||
import { format, parseISO, subDays } from "date-fns";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getResourceFields, getResources } from "@/actions/resources";
|
||||
import { getScansByFields } from "@/actions/scans";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { ColumnResources } from "@/components/resources/table/column-resources";
|
||||
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { createDict } from "@/lib";
|
||||
import { ResourceProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Resources({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) {
|
||||
const searchParamsKey = JSON.stringify(searchParams || {});
|
||||
const twoDaysAgo = format(subDays(new Date(), 2), "yyyy-MM-dd");
|
||||
|
||||
// Check if the searchParams contain any date or filter
|
||||
const hasDateOrScanFilter = Object.keys(searchParams).some((key) =>
|
||||
key.includes("inserted_at"),
|
||||
);
|
||||
|
||||
// Default filters for getFindings
|
||||
const defaultFilters: Record<string, string> = hasDateOrScanFilter
|
||||
? {} // Do not apply default filters if there are date or filters
|
||||
: { "filter[inserted_at]": twoDaysAgo, "page[size]": "100" }; // TODO: Remove page[size] 100 when metadata endpoint implemented
|
||||
|
||||
const filters: Record<string, string> = {
|
||||
...defaultFilters,
|
||||
...Object.fromEntries(
|
||||
Object.entries(searchParams)
|
||||
.filter(([key]) => key.startsWith("filter["))
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(",") : value?.toString() || "",
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
// Fetch scans data latest date not fully done
|
||||
const scansData = await getScansByFields("inserted_at", {
|
||||
"filter[state]": "completed",
|
||||
});
|
||||
|
||||
if (scansData.data?.length !== 0) {
|
||||
const latestScandate = scansData.data[0].attributes.inserted_at;
|
||||
const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd");
|
||||
if (!hasDateOrScanFilter) {
|
||||
filters["filter[inserted_at]"] = formattedDate;
|
||||
}
|
||||
}
|
||||
|
||||
const resourcesData = await getResourceFields(
|
||||
"name,type,region,service",
|
||||
filters,
|
||||
);
|
||||
|
||||
let resourceNameList: string[] = [];
|
||||
let typeList: string[] = [];
|
||||
let regionList: string[] = [];
|
||||
let serviceList: string[] = [];
|
||||
|
||||
if (resourcesData?.data) {
|
||||
resourceNameList = Array.from(
|
||||
new Set(
|
||||
resourcesData.data.map((item: ResourceProps) => item.attributes.name) ||
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
typeList = Array.from(
|
||||
new Set(
|
||||
resourcesData.data.map((item: ResourceProps) => item.attributes.type) ||
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
regionList = Array.from(
|
||||
new Set(
|
||||
resourcesData.data.map(
|
||||
(item: ResourceProps) => item.attributes.region,
|
||||
) || [],
|
||||
),
|
||||
);
|
||||
|
||||
serviceList = Array.from(
|
||||
new Set(
|
||||
resourcesData.data.map(
|
||||
(item: ResourceProps) => item.attributes.service,
|
||||
) || [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Resources" icon="carbon:data-view">
|
||||
<FilterControls search date />
|
||||
<Spacer y={8} />
|
||||
<DataTableFilterCustom
|
||||
filters={[
|
||||
{
|
||||
key: "name",
|
||||
labelCheckboxGroup: "Resources",
|
||||
values: resourceNameList,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
labelCheckboxGroup: "Region",
|
||||
values: regionList,
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
labelCheckboxGroup: "Type",
|
||||
values: typeList,
|
||||
},
|
||||
{
|
||||
key: "service",
|
||||
labelCheckboxGroup: "Service",
|
||||
values: serviceList,
|
||||
},
|
||||
]}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
|
||||
<SSRDataTable searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRDataTable = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
|
||||
const defaultSort = "name";
|
||||
const sort = searchParams.sort?.toString() || defaultSort;
|
||||
|
||||
const twoDaysAgo = format(subDays(new Date(), 2), "yyyy-MM-dd");
|
||||
|
||||
// Check if the searchParams contain any date or filter
|
||||
const hasDateOrScanFilter = Object.keys(searchParams).some((key) =>
|
||||
key.includes("inserted_at"),
|
||||
);
|
||||
|
||||
// Default filters for getFindings
|
||||
const defaultFilters: Record<string, string> = hasDateOrScanFilter
|
||||
? {} // Do not apply default filters if there are date or filters
|
||||
: { "filter[inserted_at]": twoDaysAgo };
|
||||
|
||||
const filters: Record<string, string> = {
|
||||
...defaultFilters,
|
||||
...Object.fromEntries(
|
||||
Object.entries(searchParams)
|
||||
.filter(([key]) => key.startsWith("filter["))
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(",") : value?.toString() || "",
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
// Fetch scans data latest date
|
||||
const scansData = await getScansByFields("inserted_at", {
|
||||
"filter[state]": "completed",
|
||||
});
|
||||
|
||||
if (scansData.data?.length !== 0) {
|
||||
const latestScandate = scansData.data[0].attributes.inserted_at;
|
||||
const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd");
|
||||
if (!hasDateOrScanFilter) {
|
||||
filters["filter[inserted_at]"] = formattedDate;
|
||||
}
|
||||
}
|
||||
|
||||
const query = filters["filter[search]"] || "";
|
||||
const resourcesData = await getResources({
|
||||
query,
|
||||
page,
|
||||
filters,
|
||||
sort,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const findingsDict = createDict("findings", resourcesData);
|
||||
const providerDict = createDict("providers", resourcesData);
|
||||
|
||||
// Expand each resources with its corresponding findings and provider
|
||||
const expandedResources = resourcesData?.data
|
||||
? resourcesData.data.map((resource: ResourceProps) => {
|
||||
const findings = {
|
||||
meta: resource.relationships.findings.meta,
|
||||
data: resource.relationships.findings.data?.map(
|
||||
(finding) => findingsDict[finding.id],
|
||||
),
|
||||
};
|
||||
|
||||
const provider = {
|
||||
data: providerDict[resource.relationships.provider.data.id],
|
||||
};
|
||||
|
||||
return {
|
||||
...resource,
|
||||
relationships: { findings, provider },
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const expandedResponse = {
|
||||
...resourcesData,
|
||||
data: expandedResources,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={ColumnResources}
|
||||
data={expandedResponse?.data || []}
|
||||
metadata={resourcesData?.meta}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
export const SkeletonFindingSummary = () => {
|
||||
return (<div className="flex animate-pulse flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="h-5 w-1/3 rounded bg-default-200" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-16 rounded bg-default-200" />
|
||||
<div className="h-5 w-16 rounded bg-default-200" />
|
||||
<div className="h-5 w-5 rounded-full bg-default-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Card, Skeleton } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
|
||||
export const SkeletonTableResources = () => {
|
||||
return (
|
||||
<Card className="h-full w-full space-y-5 p-4" radius="sm">
|
||||
{/* Table headers */}
|
||||
<div className="hidden justify-between md:flex">
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-2/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="w-1/12 rounded-lg">
|
||||
<div className="h-8 bg-default-200"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
|
||||
>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
|
||||
<div className="h-12 bg-default-300"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { EntityInfoShort } from "@/components/ui/entities";
|
||||
import { TriggerSheet } from "@/components/ui/sheet";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { ResourceProps } from "@/types";
|
||||
|
||||
import { DataTableRowDetails } from "./data-table-row-details";
|
||||
|
||||
const getResourceData = (
|
||||
row: { original: ResourceProps },
|
||||
field: keyof ResourceProps["attributes"],
|
||||
) => {
|
||||
return row.original.attributes?.[field] || `No ${field} found in resource`;
|
||||
};
|
||||
|
||||
const getProviderData = (
|
||||
row: { original: ResourceProps },
|
||||
field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"],
|
||||
) => {
|
||||
return (
|
||||
row.original.relationships?.provider?.data?.attributes?.[field] ??
|
||||
`No ${field} found in provider`
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceDetailsCell = ({ row }: { row: any }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const resourceId = searchParams.get("resourceId");
|
||||
const isOpen = resourceId === row.original.id;
|
||||
|
||||
return (
|
||||
<div className="flex w-9 items-center justify-center">
|
||||
<TriggerSheet
|
||||
triggerComponent={<InfoIcon className="text-primary" size={16} />}
|
||||
title="Resource Details"
|
||||
description="View the Resource details"
|
||||
defaultOpen={isOpen}
|
||||
>
|
||||
<DataTableRowDetails
|
||||
resourceData={row.original}
|
||||
resourceId={row.original.id}
|
||||
/>
|
||||
</TriggerSheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnResources: ColumnDef<ResourceProps>[] = [
|
||||
{
|
||||
id: "moreInfo",
|
||||
header: "Details",
|
||||
cell: ({ row }) => <ResourceDetailsCell row={row} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Resource Name"}
|
||||
param="name"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceName = getResourceData(row, "name");
|
||||
return (
|
||||
<>
|
||||
<div className="w-[120px] whitespace-normal break-words text-xs">
|
||||
{typeof resourceName === "string" ? resourceName : "Invalid name"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "failedFindings",
|
||||
header: "Failed Findings",
|
||||
cell: ({ row }) => {
|
||||
const count = row.original.relationships.findings.data.filter(
|
||||
(data) => data.attributes.status === "FAIL",
|
||||
).length;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs">{count}</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "region",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={"Region"} param="region" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const region = getResourceData(row, "region");
|
||||
|
||||
return (
|
||||
<div className="w-[80px] text-xs">
|
||||
{typeof region === "string" ? region : "Invalid region"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={"Type"} param="type" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type = getResourceData(row, "type");
|
||||
|
||||
return (
|
||||
<div className="w-[120px] whitespace-normal break-words text-xs">
|
||||
{typeof type === "string" ? type : "Invalid type"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "service",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Service"}
|
||||
param="service"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const service = getResourceData(row, "service");
|
||||
|
||||
return (
|
||||
<div className="w-[80px] text-xs">
|
||||
{typeof service === "string" ? service : "Invalid region"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: "Cloud Provider",
|
||||
cell: ({ row }) => {
|
||||
const provider = getProviderData(row, "provider");
|
||||
const alias = getProviderData(row, "alias");
|
||||
const uid = getProviderData(row, "uid");
|
||||
return (
|
||||
<>
|
||||
<EntityInfoShort
|
||||
cloudProvider={provider as "aws" | "azure" | "gcp" | "kubernetes"}
|
||||
entityAlias={alias as string}
|
||||
entityId={uid as string}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getResourceById } from "@/actions/resources";
|
||||
import { ResourceApiResponse, ResourceProps } from "@/types";
|
||||
|
||||
import { ResourceDetail } from "./resource-detail";
|
||||
|
||||
export const DataTableRowDetails = ({
|
||||
resourceId,
|
||||
resourceData,
|
||||
}: {
|
||||
resourceId: string;
|
||||
resourceData: ResourceProps;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [resourceDetails, setResourceDetails] =
|
||||
useState<ResourceApiResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScanDetails = async () => {
|
||||
try {
|
||||
const result = await getResourceById(resourceId);
|
||||
setResourceDetails(result);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error in fetchScanDetails:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScanDetails();
|
||||
}, [resourceId]);
|
||||
|
||||
return (
|
||||
<ResourceDetail
|
||||
resourceData={resourceData}
|
||||
resourceDetails={resourceDetails}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./column-resources";
|
||||
export * from "./data-table-row-details";
|
||||
export * from "./resource-detail";
|
||||
export * from "../skeleton/skeleton-table-resources";
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Snippet } from "@nextui-org/react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
DateWithTime,
|
||||
getProviderLogo,
|
||||
InfoField,
|
||||
ProviderType,
|
||||
} from "@/components/ui/entities";
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { ResourceApiResponse, ResourceProps } from "@/types";
|
||||
import { SkeletonFindingSummary } from "../skeleton/skeleton-finding-summary";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
const Section = ({
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-md font-medium text-gray-800 dark:text-prowler-theme-pale/90">
|
||||
{title}
|
||||
</h3>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ResourceDetail = ({
|
||||
resourceDetails,
|
||||
resourceData,
|
||||
isLoading,
|
||||
}: {
|
||||
resourceDetails: ResourceApiResponse | null;
|
||||
resourceData: ResourceProps;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const failedFindings = resourceDetails?.included.filter(
|
||||
(item) =>
|
||||
item.type === "findings" &&
|
||||
item.attributes?.status === "FAIL" &&
|
||||
item.attributes?.delta === "new",
|
||||
);
|
||||
|
||||
const linkToFindingsFromResources = (
|
||||
uid: string,
|
||||
inserted_at: string,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const formattedDate = format(parseISO(inserted_at), "yyyy-MM-dd");
|
||||
router.push(
|
||||
`/findings?filter[uid]=${uid}&filter[inserted_at]=${formattedDate}&id=${resourceId}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 rounded-lg">
|
||||
{/* Resource Details section */}
|
||||
<Section title="Resource Details">
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<Snippet className="bg-gray-50 py-1 dark:bg-slate-800" hideSymbol>
|
||||
<span className="whitespace-pre-line text-xs">
|
||||
{renderValue(resourceData?.attributes.uid)}
|
||||
</span>
|
||||
</Snippet>
|
||||
</InfoField>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(resourceData.attributes.name)}
|
||||
</InfoField>
|
||||
<InfoField label="Resource Type">
|
||||
{renderValue(resourceData.attributes.type)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Service">
|
||||
{renderValue(resourceData.attributes.service)}
|
||||
</InfoField>
|
||||
<InfoField label="Region">
|
||||
{renderValue(resourceData.attributes.region)}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={resourceData.attributes.inserted_at}
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime
|
||||
inline
|
||||
dateTime={resourceData.attributes.updated_at}
|
||||
/>
|
||||
</InfoField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Provider Details section */}
|
||||
<Section title="Provider Details">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{resourceData.relationships.provider.data.attributes.alias && (
|
||||
<InfoField label="Alias">
|
||||
{resourceData.relationships.provider.data.attributes.alias}
|
||||
</InfoField>
|
||||
)}
|
||||
<InfoField label="Account ID">
|
||||
{resourceData.relationships.provider.data.attributes.uid}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Provider" variant="simple">
|
||||
{getProviderLogo(
|
||||
resourceData.relationships.provider.data.attributes
|
||||
.provider as ProviderType,
|
||||
)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Finding Details section */}
|
||||
<div>
|
||||
<h2 className="line-clamp-2 text-lg font-medium leading-tight text-gray-800 dark:text-prowler-theme-pale/90">
|
||||
Findings Details
|
||||
</h2>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<SkeletonFindingSummary />
|
||||
) : failedFindings && failedFindings?.length > 0 ? (
|
||||
failedFindings.map((finding, index) => {
|
||||
const { attributes, id } = finding;
|
||||
const { severity, uid, inserted_at, check_metadata, status } =
|
||||
attributes;
|
||||
|
||||
const { checktitle } = check_metadata;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-800 dark:text-prowler-theme-pale/90">
|
||||
{checktitle}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SeverityBadge severity={severity || "-"} />
|
||||
<StatusFindingBadge status={status || "-"} />
|
||||
<InfoIcon
|
||||
className="cursor-pointer text-primary"
|
||||
size={16}
|
||||
onClick={() =>
|
||||
linkToFindingsFromResources(uid, inserted_at, id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-600 dark:text-prowler-theme-pale/80">
|
||||
No data found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+20
-1
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
LayoutGrid,
|
||||
Mail,
|
||||
Package,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SquareChartGantt,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -114,7 +116,24 @@ export const getMenuList = (pathname: string): GroupProps[] => {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
groupLabel: "Inventory",
|
||||
menus: [
|
||||
{
|
||||
href: "",
|
||||
label: "Resources",
|
||||
icon: Warehouse,
|
||||
submenus: [
|
||||
{
|
||||
href: "/resources",
|
||||
label: "Browse all resources",
|
||||
icon: Package,
|
||||
},
|
||||
],
|
||||
defaultOpen: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "Settings",
|
||||
menus: [
|
||||
|
||||
@@ -767,3 +767,145 @@ export interface UserProps {
|
||||
dateAdded: string;
|
||||
status: "active" | "inactive";
|
||||
}
|
||||
|
||||
export interface ResourceProps {
|
||||
type: "resources";
|
||||
id: string;
|
||||
attributes: {
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
region: string;
|
||||
service: string;
|
||||
tags: Record<string, string>;
|
||||
type: string;
|
||||
};
|
||||
relationships: {
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers";
|
||||
id: string;
|
||||
attributes: {
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
provider: string;
|
||||
uid: string;
|
||||
alias: string | null;
|
||||
connection: {
|
||||
connected: boolean;
|
||||
last_checked_at: string;
|
||||
};
|
||||
};
|
||||
relationships: {
|
||||
secret: {
|
||||
data: {
|
||||
type: "provider-secrets";
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
links: {
|
||||
self: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
findings: {
|
||||
meta: {
|
||||
count: number;
|
||||
};
|
||||
data: {
|
||||
type: "findings";
|
||||
id: string;
|
||||
attributes: { status: string };
|
||||
}[];
|
||||
};
|
||||
};
|
||||
links: {
|
||||
self: string;
|
||||
};
|
||||
}
|
||||
interface Provider {
|
||||
type: "providers" | "findings";
|
||||
id: string;
|
||||
attributes: {
|
||||
uid: string;
|
||||
delta: string;
|
||||
status: "PASS" | "FAIL" | "MANUAL";
|
||||
status_extended: string;
|
||||
severity: "informational" | "low" | "medium" | "high" | "critical";
|
||||
check_id: string;
|
||||
check_metadata: CheckMetadata;
|
||||
raw_result: Record<string, any>;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
first_seen_at: string;
|
||||
muted: boolean;
|
||||
};
|
||||
relationships: {
|
||||
secret: {
|
||||
data: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
scan: {
|
||||
data: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: number;
|
||||
};
|
||||
data: [];
|
||||
};
|
||||
};
|
||||
links: {
|
||||
self: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckMetadata {
|
||||
risk: string;
|
||||
notes: string;
|
||||
checkid: string;
|
||||
provider: string;
|
||||
severity: string;
|
||||
checktype: string[];
|
||||
dependson: string[];
|
||||
relatedto: string[];
|
||||
categories: string[];
|
||||
checktitle: string;
|
||||
compliance: any;
|
||||
relatedurl: string;
|
||||
description: string;
|
||||
remediation: {
|
||||
code: {
|
||||
cli: string;
|
||||
other: string;
|
||||
nativeiac: string;
|
||||
terraform: string;
|
||||
};
|
||||
recommendation: {
|
||||
url: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
servicename: string;
|
||||
checkaliases: string[];
|
||||
resourcetype: string;
|
||||
subservicename: string;
|
||||
resourceidtemplate: string;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ResourceApiResponse {
|
||||
data: ResourceProps;
|
||||
included: Provider[];
|
||||
meta: Meta;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user