Merge remote-tracking branch 'origin/feature/add-resource-inventory' into showdown-demo

This commit is contained in:
Pepe Fagoaga
2025-06-04 11:43:26 +02:00
18 changed files with 1084 additions and 11 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./resources";
+101
View File
@@ -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),
};
}
};
+10 -8
View File
@@ -16,6 +16,12 @@ export const getScans = async ({
sort = "",
filters = {},
pageSize = 10,
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | number | boolean>;
pageSize?: number;
}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -28,18 +34,14 @@ export const getScans = async ({
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
// Add dynamic filters (e.g., "filter[state]", "fields[scans]")
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
url.searchParams.append(key, String(value));
});
try {
const scans = await fetch(url.toString(), {
headers,
});
const data = await scans.json();
const response = await fetch(url.toString(), { headers });
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/scans");
return parsedData;
+218
View File
@@ -0,0 +1,218 @@
import { Spacer } from "@nextui-org/react";
import { format, parseISO } from "date-fns";
import { Suspense } from "react";
import { getResourceFields, getResources } from "@/actions/resources";
import { getScans } from "@/actions/scans";
import { FilterControls } from "@/components/filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ColumnResources } from "@/components/resources/table/column-resources";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import {
createDict,
extractFiltersAndQuery,
extractSortAndKey,
hasDateOrScanFilter,
replaceFilterFieldKey,
} from "@/lib";
import { ResourceProps, SearchParamsProps } from "@/types";
export default async function Resources({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
// Check if the searchParams contain any date or filter
const hasDateOrScan = hasDateOrScanFilter(searchParams);
const { filters } = extractFiltersAndQuery(searchParams);
filters["page[size]"] = "100"; // TODO: Remove page[size] 100 when metadata endpoint implemented
if (!hasDateOrScan) {
const scansData = await getScans({
filters: {
"fields[scans]": "inserted_at",
},
});
if (scansData?.data?.length !== 0) {
const latestScandate = scansData.data?.[0]?.attributes?.inserted_at;
if (latestScandate) {
const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd");
filters["filter[updated_at]"] = formattedDate;
}
}
}
const outputFilters = replaceFilterFieldKey(
filters,
"inserted_at",
"updated_at",
);
// Resource call for filters
const resourcesData = await getResourceFields(
"name,type,region,service",
outputFilters,
);
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 pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const defaultSort = "name";
const { encodedSort } = extractSortAndKey({
...searchParams,
sort: searchParams.sort ?? defaultSort,
});
// Check if the searchParams contain any date or filter
const hasDateOrScan = hasDateOrScanFilter(searchParams);
const { filters, query } = extractFiltersAndQuery(searchParams);
if (!hasDateOrScan) {
// Fetch scans data latest date
const scansData = await getScans({
filters: {
"fields[scans]": "inserted_at",
},
});
if (scansData?.data?.length !== 0) {
const latestScandate = scansData?.data?.[0]?.attributes?.inserted_at;
const formattedDate = format(parseISO(latestScandate), "yyyy-MM-dd");
filters["filter[updated_at]"] = formattedDate;
}
}
const outputFilters = replaceFilterFieldKey(
filters,
"inserted_at",
"updated_at",
);
const resourcesData = await getResources({
query,
page,
filters: outputFilters,
sort: encodedSort,
pageSize,
});
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}
/>
);
};
+7 -1
View File
@@ -147,7 +147,13 @@ const SSRDataTableScans = async ({
const query = (filters["filter[search]"] as string) || "";
// Fetch scans data
const scansData = await getScans({ query, page, sort, filters, pageSize });
const scansData = await getScans({
query,
page,
sort,
filters: filters as Record<string, string | number | boolean>,
pageSize,
});
// Handle expanded scans data
const expandedScansData = await Promise.all(
@@ -0,0 +1,16 @@
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,180 @@
"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 getChipStyle = (count: number) => {
if (count === 0) return "bg-green-100 text-green-800";
if (count >= 10) return "bg-red-100 text-red-800";
if (count >= 1) return "bg-yellow-100 text-yellow-800";
};
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="relative flex max-w-[370px] flex-row items-center gap-2 3xl:max-w-[660px]">
<div className="flex w-full flex-row items-center gap-4">
<p className="w-full whitespace-normal break-words text-sm">
{typeof resourceName === "string"
? resourceName
: "Invalid name"}
</p>
</div>
</div>
</>
);
},
},
{
accessorKey: "failedFindings",
header: () => <div className="text-center">Failed Findings</div>,
cell: ({ row }) => {
const count = row.original.relationships.findings.data.filter(
(data) =>
data.attributes.status === "FAIL" && data.attributes.delta === "new",
).length;
return (
<>
<p className="text-center">
<span
className={`mx-auto flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(count)}`}
>
{count}
</span>
</p>
</>
);
},
},
{
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="max-w-[150px] 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="max-w-96 truncate 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}
/>
);
};
+4
View File
@@ -0,0 +1,4 @@
export * from "../skeleton/skeleton-table-resources";
export * from "./column-resources";
export * from "./data-table-row-details";
export * from "./resource-detail";
@@ -0,0 +1,179 @@
import { Snippet } from "@nextui-org/react";
import { format, parseISO } from "date-fns";
import { InfoIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import {
DateWithTime,
EntityInfoShort,
InfoField,
} 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">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<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="flex w-full items-end justify-between space-x-2">
<EntityInfoShort
cloudProvider={
resourceData.relationships.provider.data.attributes.provider as
| "aws"
| "azure"
| "gcp"
| "kubernetes"
}
entityAlias={
resourceData.relationships.provider.data.attributes
.alias as string
}
entityId={
resourceData.relationships.provider.data.attributes
.uid as string
}
/>
</div>
</div>
<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>
{/* Finding associated with this resource section */}
<h2 className="text-md line-clamp-2 font-medium leading-tight text-gray-800 dark:text-prowler-theme-pale/90">
Findings associated with this resource
</h2>
{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-2 rounded-lg px-4 py-2 shadow-small dark:bg-prowler-blue-400"
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-gray-800 dark:text-prowler-theme-pale/90">
{checktitle}
</h3>
<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>
);
};
+1 -1
View File
@@ -64,7 +64,7 @@ export const InfoField = ({
</span>
{variant === "simple" ? (
<div className="text-small text-gray-900 dark:text-prowler-theme-pale">
<div className="break-all text-small text-gray-900 dark:text-prowler-theme-pale">
{children}
</div>
) : variant === "transparent" ? (
@@ -0,0 +1,66 @@
import { AlertIcon } from "@/components/icons/Icons";
import { cn } from "@/lib/utils";
type FeedbackType = "error" | "warning" | "info" | "success";
interface FeedbackBannerProps {
type?: FeedbackType;
title: string;
message: string;
className?: string;
}
const typeStyles: Record<
FeedbackType,
{ border: string; bg: string; text: string }
> = {
error: {
border: "border-danger",
bg: "bg-system-error-light/30 dark:bg-system-error-light/80",
text: "text-danger",
},
warning: {
border: "border-warning",
bg: "bg-yellow-100 dark:bg-yellow-200",
text: "text-yellow-800",
},
info: {
border: "border-blue-400",
bg: "bg-blue-50 dark:bg-blue-100",
text: "text-blue-800",
},
success: {
border: "border-green-500",
bg: "bg-green-50 dark:bg-green-100",
text: "text-green-800",
},
};
export const FeedbackBanner: React.FC<FeedbackBannerProps> = ({
type = "info",
title,
message,
className,
}) => {
const styles = typeStyles[type];
return (
<div
className={cn(
"rounded-xl border-l-4 p-4 shadow-sm",
styles.border,
styles.bg,
className,
)}
>
<div className="flex items-center gap-3">
<span className={cn("mt-1", styles.text)}>
<AlertIcon size={20} />
</span>
<p className={cn("text-sm", styles.text)}>
<strong>{title}</strong> {message}
</p>
</div>
</div>
);
};
+1
View File
@@ -7,6 +7,7 @@ export * from "./content-layout/content-layout";
export * from "./dialog/dialog";
export * from "./download-icon-button/download-icon-button";
export * from "./dropdown/Dropdown";
export * from "./feedback-banner/feedback-banner";
export * from "./headers/navigation-header";
export * from "./label/Label";
export * from "./main-layout/main-layout";
+27
View File
@@ -44,3 +44,30 @@ export const extractSortAndKey = (searchParams: Record<string, unknown>) => {
return { searchParamsKey, rawSort, encodedSort };
};
/**
* Replaces a specific field name inside a filter-style key of an object.
* @param obj - The input object with filter-style keys (e.g., { 'filter[inserted_at]': '2025-05-21' }).
* @param oldField - The field name to be replaced (e.g., 'inserted_at').
* @param newField - The field name to replace with (e.g., 'updated_at').
* @returns A new object with the updated filter key if a match is found.
*/
export function replaceFilterFieldKey(
obj: Record<string, string>,
oldField: string,
newField: string,
): Record<string, string> {
const fieldObj: Record<string, string> = {};
for (const key in obj) {
const match = key.match(/^filter\[(.+)\]$/);
if (match && match[1] === oldField) {
const newKey = `filter[${newField}]`;
fieldObj[newKey] = obj[key];
} else {
fieldObj[key] = obj[key];
}
}
return fieldObj;
}
+20 -1
View File
@@ -9,6 +9,7 @@ import {
Group,
LayoutGrid,
Mail,
Package,
Settings,
ShieldCheck,
SquareChartGantt,
@@ -18,6 +19,7 @@ import {
User,
UserCog,
Users,
Warehouse,
} from "lucide-react";
import {
@@ -122,7 +124,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: [
+1
View File
@@ -3,4 +3,5 @@ export * from "./components";
export * from "./filters";
export * from "./formSchemas";
export * from "./providers";
export * from "./resources";
export * from "./scans";
+142
View File
@@ -0,0 +1,142 @@
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; delta: string };
}[];
};
};
links: {
self: string;
};
}
interface ResourceItemProps {
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: CheckMetadataProps;
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 CheckMetadataProps {
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: ResourceItemProps[];
meta: Meta;
}