feat: resource view re-styling with new components (#9864)

This commit is contained in:
Alejandro Bailo
2026-01-28 14:07:01 +01:00
committed by GitHub
parent c77d9dd3a9
commit 0c5778d4a1
59 changed files with 2288 additions and 867 deletions
+1
View File
@@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🚀 Added
- Attack Paths: Bedrock Code Interpreter and AttachRolePolicy privilege escalation queries [(#9885)](https://github.com/prowler-cloud/prowler/pull/9885)
- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
- Added memory optimizations for large compliance report generation [(#9444)](https://github.com/prowler-cloud/prowler/pull/9444)
- `GET /api/v1/resources/{id}/events` endpoint to retrieve AWS resource modification history from CloudTrail [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
- Partial index on findings to speed up new failed findings queries [(#9904)](https://github.com/prowler-cloud/prowler/pull/9904)
+4
View File
@@ -453,6 +453,8 @@ class ResourceTagFilter(FilterSet):
class ResourceFilter(ProviderRelationshipFilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
@@ -540,6 +542,8 @@ class ResourceFilter(ProviderRelationshipFilterSet):
class LatestResourceFilter(ProviderRelationshipFilterSet):
provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact")
provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in")
tag_key = CharFilter(method="filter_tag_key")
tag_value = CharFilter(method="filter_tag_value")
tag = CharFilter(method="filter_tag")
+60
View File
@@ -8186,6 +8186,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
@@ -8646,6 +8661,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
@@ -8942,6 +8972,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
@@ -9244,6 +9289,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_id]
schema:
type: string
format: uuid
- in: query
name: filter[provider_id__in]
schema:
type: array
items:
type: string
format: uuid
description: Multiple values may be separated by commas.
explode: false
style: form
- in: query
name: filter[provider_type]
schema:
+102
View File
@@ -4356,6 +4356,108 @@ class TestResourceViewSet:
assert attributes["types"] == [latest_scan_resource.type]
assert "groups" in attributes
def test_resources_latest_filter_by_provider_id(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id filter works on latest resources endpoint."""
provider = latest_scan_resource.provider
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id]": str(provider.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert (
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
)
def test_resources_latest_filter_by_provider_id_in(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id__in filter works on latest resources endpoint."""
provider = latest_scan_resource.provider
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": str(provider.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert (
response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
)
def test_resources_latest_filter_by_provider_id_in_multiple(
self, authenticated_client, providers_fixture
):
"""Test that provider_id__in filter works with multiple provider IDs."""
provider1, provider2 = providers_fixture[0], providers_fixture[1]
tenant_id = str(provider1.tenant_id)
# Create completed scans for both providers
Scan.objects.create(
name="scan for provider 1",
provider=provider1,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant_id,
)
Scan.objects.create(
name="scan for provider 2",
provider=provider2,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant_id,
)
# Create resources for each provider
resource1 = Resource.objects.create(
tenant_id=tenant_id,
provider=provider1,
uid="resource_provider_1",
name="Resource Provider 1",
region="us-east-1",
service="ec2",
type="instance",
)
Resource.objects.create(
tenant_id=tenant_id,
provider=provider2,
uid="resource_provider_2",
name="Resource Provider 2",
region="us-west-2",
service="s3",
type="bucket",
)
# Test filtering by both providers
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 2
# Test filtering by single provider returns only that provider's resource
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id__in]": str(provider1.id)},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid
def test_resources_latest_filter_by_provider_id_no_match(
self, authenticated_client, latest_scan_resource
):
"""Test that provider_id filter returns empty when no match."""
non_existent_id = str(uuid4())
response = authenticated_client.get(
reverse("resource-latest"),
{"filter[provider_id]": non_existent_id},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
# Events endpoint tests
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
"""Test events endpoint rejects non-AWS providers."""
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864)
- Launch Scan page now displays all providers without pagination limit [(#9700)](https://github.com/prowler-cloud/prowler/pull/9700)
- Upgrade Next.js from 15.5.9 to 16.1.3 with ESLint 9 flat config migration [(#9826)](https://github.com/prowler-cloud/prowler/pull/9826)
@@ -32,7 +32,7 @@ export const TimeRangeSelector = ({
isLoading = false,
}: TimeRangeSelectorProps) => {
return (
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center gap-2 rounded-full border">
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary inline-flex items-center rounded-full border">
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
<button
key={key}
+5 -4
View File
@@ -16,6 +16,7 @@ import {
SkeletonTableFindings,
} from "@/components/findings/table";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import {
createDict,
createScanDetailsMapping,
@@ -36,8 +37,7 @@ export default async function Findings({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const { searchParamsKey, encodedSort } =
extractSortAndKey(resolvedSearchParams);
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
// Check if the searchParams contain any date or scan filter
@@ -100,7 +100,6 @@ export default async function Findings({
const resourceId = finding.relationships?.resources?.data?.[0]?.id;
const scan = scanId ? scanDict[scanId] : undefined;
const providerId = scan?.relationships?.provider?.data?.id;
const resource = resourceId ? resourceDict[resourceId] : undefined;
const provider = providerId ? providerDict[providerId] : undefined;
@@ -148,6 +147,7 @@ export default async function Findings({
return (
<ContentLayout title="Findings" icon="lucide:tag">
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
@@ -163,9 +163,10 @@ export default async function Findings({
uniqueGroups={uniqueGroups}
/>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</FilterTransitionWrapper>
{processedFinding && <FindingDetailsSheet finding={processedFinding} />}
</ContentLayout>
);
@@ -14,8 +14,8 @@ import {
import { DeleteLLMProviderForm } from "@/components/lighthouse/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse/workflow";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { NavigationHeader } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import type { LighthouseProvider } from "@/types/lighthouse";
interface ConnectLLMLayoutProps {
@@ -63,8 +63,8 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your LLM provider configuration and remove your data from the server."
@@ -73,7 +73,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
providerType={provider}
setIsOpen={setIsDeleteOpen}
/>
</CustomAlertModal>
</Modal>
<NavigationHeader
title={isEditMode ? "Configure LLM Provider" : "Connect LLM Provider"}
@@ -11,8 +11,8 @@ import {
updateMutedFindingsConfig,
} from "@/actions/processors";
import { Button, Card, Skeleton } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
import {
@@ -152,8 +152,8 @@ export function AdvancedMutelistForm() {
return (
<>
{/* Delete Confirmation Modal */}
<CustomAlertModal
isOpen={showDeleteConfirmation}
<Modal
open={showDeleteConfirmation}
onOpenChange={setShowDeleteConfirmation}
title="Delete Mutelist Configuration"
size="md"
@@ -185,7 +185,7 @@ export function AdvancedMutelistForm() {
</Button>
</div>
</div>
</CustomAlertModal>
</Modal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
@@ -8,8 +8,8 @@ import { useActionState, useEffect, useRef, useState } from "react";
import { deleteMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps } from "@/types";
@@ -82,8 +82,8 @@ export function MuteRulesTableClient({
{/* Edit Modal */}
{selectedMuteRule && (
<CustomAlertModal
isOpen={editModal.isOpen}
<Modal
open={editModal.isOpen}
onOpenChange={editModal.onOpenChange}
title="Edit Mute Rule"
size="lg"
@@ -93,13 +93,13 @@ export function MuteRulesTableClient({
onSuccess={handleEditSuccess}
onCancel={editModal.onClose}
/>
</CustomAlertModal>
</Modal>
)}
{/* Delete Confirmation Modal */}
{selectedMuteRule && (
<CustomAlertModal
isOpen={deleteModal.isOpen}
<Modal
open={deleteModal.isOpen}
onOpenChange={deleteModal.onOpenChange}
title="Delete Mute Rule"
size="md"
@@ -138,7 +138,7 @@ export function MuteRulesTableClient({
</form>
</div>
</div>
</CustomAlertModal>
</Modal>
)}
</>
);
+46 -26
View File
@@ -5,14 +5,15 @@ import {
getLatestMetadataInfo,
getLatestResources,
getMetadataInfo,
getResourceById,
getResources,
} from "@/actions/resources";
import { FilterControls } from "@/components/filters";
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ColumnResources } from "@/components/resources/table/column-resources";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { FilterTransitionWrapper } from "@/contexts";
import {
createDict,
extractFiltersAndQuery,
@@ -20,10 +21,6 @@ import {
hasDateOrScanFilter,
replaceFieldKey,
} from "@/lib";
import {
createProviderDetailsMappingById,
extractProviderIds,
} from "@/lib/provider-helpers";
import { ResourceProps, SearchParamsProps } from "@/types";
export default async function Resources({
@@ -32,49 +29,73 @@ export default async function Resources({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const { searchParamsKey, encodedSort } =
extractSortAndKey(resolvedSearchParams);
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
const [metadataInfoData, providersData] = await Promise.all([
// Check if there's a specific resource ID to fetch
const resourceId = resolvedSearchParams.resourceId?.toString();
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
[
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
query,
filters: outputFilters,
sort: encodedSort,
}),
getProviders({ pageSize: 50 }),
]);
resourceId
? getResourceById(resourceId, { include: ["provider"] })
: Promise.resolve(null),
],
);
// Process the resource data to match the expected structure
const processedResource = resourceByIdData?.data
? (() => {
const resource = resourceByIdData.data;
const providerDict = createDict("providers", resourceByIdData);
const provider = {
data: providerDict[resource.relationships?.provider?.data?.id],
};
return {
...resource,
relationships: {
...resource.relationships,
provider,
},
} as ResourceProps;
})()
: null;
// Extract unique regions, services, groups from the metadata endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
// Extract provider IDs and details
const providerIds = providersData ? extractProviderIds(providersData) : [];
const providerDetails = providersData
? createProviderDetailsMappingById(providerIds, providersData)
: [];
return (
<ContentLayout title="Resources" icon="lucide:warehouse">
<FilterControls search date />
<div className="flex flex-col gap-6">
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
providerIds={providerIds}
providerDetails={providerDetails}
providers={providersData?.data || []}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueGroups={uniqueGroups}
/>
<Suspense key={searchParamsKey} fallback={<SkeletonTableResources />}>
</div>
<Suspense fallback={<SkeletonTableResources />}>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</div>
</FilterTransitionWrapper>
{processedResource && (
<ResourceDetailsSheet resource={processedResource} />
)}
</ContentLayout>
);
}
@@ -112,6 +133,7 @@ const SSRDataTable = async ({
"region",
"service",
"type",
"groups",
"provider",
"inserted_at",
"updated_at",
@@ -150,9 +172,7 @@ const SSRDataTable = async ({
<p>{resourcesData.errors[0].detail}</p>
</div>
)}
<DataTable
key={`resources-${Date.now()}`}
columns={ColumnResources}
<ResourcesTableWithSelection
data={expandedResources || []}
metadata={resourcesData?.meta}
/>
+10 -1
View File
@@ -1,4 +1,5 @@
import { jwtDecode, type JwtPayload } from "jwt-decode";
import { NextResponse } from "next/server";
import NextAuth, {
type DefaultSession,
type NextAuthConfig,
@@ -277,6 +278,7 @@ export const authConfig = {
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const sessionError = auth?.error;
const isSignUpPage = nextUrl.pathname === "/sign-up";
const isSignInPage = nextUrl.pathname === "/sign-in";
@@ -284,8 +286,15 @@ export const authConfig = {
if (isSignUpPage || isSignInPage) return true;
// For all other routes, require authentication
// Return NextResponse.redirect to preserve callbackUrl for post-login redirect
if (!isLoggedIn) {
return false; // Will redirect to signIn page defined in pages config
const signInUrl = new URL("/sign-in", nextUrl.origin);
signInUrl.searchParams.set("callbackUrl", nextUrl.pathname);
// Include session error if present (e.g., RefreshAccessTokenError)
if (sessionError) {
signInUrl.searchParams.set("error", sessionError);
}
return NextResponse.redirect(signInUrl);
}
return true;
+2 -1
View File
@@ -32,6 +32,7 @@ export const SignInForm = ({
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const callbackUrl = searchParams.get("callbackUrl") || "/";
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
@@ -122,7 +123,7 @@ export const SignInForm = ({
});
if (result?.message === "Success") {
router.push("/");
router.push(callbackUrl);
} else if (result?.errors && "credentials" in result.errors) {
const message = result.errors.credentials ?? "Invalid email or password";
+22 -12
View File
@@ -4,15 +4,16 @@ import { Input, Textarea } from "@heroui/input";
import {
Dispatch,
SetStateAction,
useActionState,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { createMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { FormButtons } from "@/components/ui/form";
interface MuteFindingsModalProps {
@@ -29,6 +30,8 @@ export function MuteFindingsModal({
onComplete,
}: MuteFindingsModalProps) {
const { toast } = useToast();
const [state, setState] = useState<MuteRuleActionState | null>(null);
const [isPending, startTransition] = useTransition();
// Use refs to avoid stale closures in useEffect
const onCompleteRef = useRef(onComplete);
@@ -37,18 +40,12 @@ export function MuteFindingsModal({
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const [state, formAction, isPending] = useActionState<
MuteRuleActionState,
FormData
>(createMuteRule, null);
useEffect(() => {
if (state?.success) {
toast({
title: "Success",
description: state.success,
});
// Call onComplete BEFORE closing the modal to ensure router.refresh() executes
onCompleteRef.current?.();
onOpenChangeRef.current(false);
} else if (state?.errors?.general) {
@@ -65,13 +62,26 @@ export function MuteFindingsModal({
};
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={onOpenChange}
title="Mute Findings"
size="lg"
>
<form action={formAction} className="flex flex-col gap-4">
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
startTransition(() => {
void (async () => {
const result = await createMuteRule(null, formData);
setState(result);
})();
});
}}
>
<input
type="hidden"
name="finding_ids"
@@ -125,6 +135,6 @@ export function MuteFindingsModal({
isDisabled={isPending}
/>
</form>
</CustomAlertModal>
</Modal>
);
}
@@ -15,8 +15,8 @@ import {
sendFindingToJira,
} from "@/actions/integrations/jira-dispatch";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import {
Form,
@@ -215,8 +215,8 @@ export const SendToJiraModal = ({
// }, [issueTypes, searchIssueTypeValue]);
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={onOpenChange}
title="Send Finding to Jira"
description={
@@ -389,6 +389,6 @@ export const SendToJiraModal = ({
)}
</form>
</Form>
</CustomAlertModal>
</Modal>
);
};
@@ -7,7 +7,8 @@ import { useSearchParams } from "next/navigation";
import { FindingDetail } from "@/components/findings/table";
import { DataTableRowActions } from "@/components/findings/table";
import { Checkbox } from "@/components/shadcn";
import { DateWithTime, SnippetChip } from "@/components/ui/entities";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities";
import {
DataTableColumnHeader,
SeverityBadge,
@@ -186,7 +187,7 @@ export function getColumnFindings(
const resourceName = getResourceData(row, "name");
return (
<SnippetChip
<CodeSnippet
value={resourceName as string}
formatter={(value: string) => `...${value.slice(-10)}`}
icon={<Database size={16} />}
@@ -1,12 +1,5 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import { Row } from "@tanstack/react-table";
import { VolumeOff, VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -16,15 +9,32 @@ import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { VerticalDotsIcon } from "@/components/icons";
import { JiraIcon } from "@/components/icons/services/IconServices";
import type { FindingProps } from "@/types/components";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { FindingsSelectionContext } from "./findings-selection-context";
interface DataTableRowActionsProps {
row: Row<FindingProps>;
export interface FindingRowData {
id: string;
attributes: {
muted?: boolean;
check_metadata?: {
checktitle?: string;
};
};
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
interface DataTableRowActionsProps<T extends FindingRowData> {
row: Row<T>;
onMuteComplete?: (findingIds: string[]) => void;
}
export function DataTableRowActions<T extends FindingRowData>({
row,
onMuteComplete,
}: DataTableRowActionsProps<T>) {
const router = useRouter();
const finding = row.original;
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
@@ -67,11 +77,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
return "Mute this finding";
};
const getMuteLabel = () => {
if (isMuted) return "Muted";
if (!isMuted && isCurrentSelected && hasMultipleSelected) {
return (
<>
Mute
<span className="ml-1 text-xs text-slate-500">
({selectedFindingIds.length})
</span>
</>
);
}
return "Mute";
};
const handleMuteComplete = () => {
// Always clear selection when a finding is muted because:
// 1. If the muted finding was selected, its index now points to a different finding
// 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected
clearSelection();
if (onMuteComplete) {
onMuteComplete(getMuteIds());
return;
}
router.refresh();
};
@@ -92,61 +122,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
/>
<div className="flex items-center justify-end">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
<ActionDropdown
trigger={
<button
type="button"
aria-label="Finding actions"
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<DropdownTrigger aria-label="Finding actions">
<VerticalDotsIcon
size={20}
className="text-text-neutral-secondary"
/>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Finding actions"
color="default"
variant="flat"
</button>
}
ariaLabel="Finding actions"
>
<DropdownSection title="Actions">
<DropdownItem
key="mute"
description={getMuteDescription()}
textValue="Mute"
isDisabled={isMuted}
startContent={
<ActionDropdownItem
icon={
isMuted ? (
<VolumeOff className="text-default-300 pointer-events-none size-5 shrink-0" />
<VolumeOff className="size-5" />
) : (
<VolumeX className="text-default-500 pointer-events-none size-5 shrink-0" />
<VolumeX className="size-5" />
)
}
onPress={() => setIsMuteModalOpen(true)}
>
{isMuted ? "Muted" : "Mute"}
{!isMuted && isCurrentSelected && hasMultipleSelected && (
<span className="ml-1 text-xs text-slate-500">
({selectedFindingIds.length})
</span>
)}
</DropdownItem>
<DropdownItem
key="jira"
description="Create a Jira issue for this finding"
textValue="Send to Jira"
startContent={
<JiraIcon
size={20}
className="text-default-500 pointer-events-none shrink-0"
label={getMuteLabel()}
description={getMuteDescription()}
disabled={isMuted}
onSelect={() => {
setIsMuteModalOpen(true);
}}
/>
}
onPress={() => setIsJiraModalOpen(true)}
>
Send to Jira
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
<ActionDropdownItem
icon={<JiraIcon size={20} />}
label="Send to Jira"
description="Create a Jira issue for this finding"
onSelect={() => setIsJiraModalOpen(true)}
/>
</ActionDropdown>
</div>
</>
);
@@ -181,13 +181,13 @@ export const FindingDetail = ({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<InfoField label="Check ID" variant="simple">
<CodeSnippet value={attributes.check_id} />
<CodeSnippet value={attributes.check_id} className="max-w-full" />
</InfoField>
<InfoField label="Finding ID" variant="simple">
<CodeSnippet value={findingDetails.id} />
<CodeSnippet value={findingDetails.id} className="max-w-full" />
</InfoField>
<InfoField label="Finding UID" variant="simple">
<CodeSnippet value={attributes.uid} />
<CodeSnippet value={attributes.uid} className="max-w-full" />
</InfoField>
<InfoField label="First seen" variant="simple">
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
@@ -16,8 +16,8 @@ import {
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
@@ -209,8 +209,8 @@ export const JiraIntegrationsManager = ({
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete Jira Integration"
description="This action cannot be undone. This will permanently delete your Jira integration."
@@ -243,10 +243,10 @@ export const JiraIntegrationsManager = ({
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</CustomAlertModal>
</Modal>
<CustomAlertModal
isOpen={isModalOpen}
<Modal
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editingIntegration
@@ -259,7 +259,7 @@ export const JiraIntegrationsManager = ({
onSuccess={handleFormSuccess}
onCancel={handleModalClose}
/>
</CustomAlertModal>
</Modal>
<div className="flex flex-col gap-6">
{/* Header with Add Button */}
@@ -16,8 +16,8 @@ import {
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
@@ -209,8 +209,8 @@ export const S3IntegrationsManager = ({
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete S3 Integration"
description="This action cannot be undone. This will permanently delete your S3 integration."
@@ -243,10 +243,10 @@ export const S3IntegrationsManager = ({
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</CustomAlertModal>
</Modal>
<CustomAlertModal
isOpen={isModalOpen}
<Modal
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editMode === "configuration"
@@ -265,7 +265,7 @@ export const S3IntegrationsManager = ({
onCancel={handleModalClose}
editMode={editMode}
/>
</CustomAlertModal>
</Modal>
<div className="flex flex-col gap-6">
{/* Header with Add Button */}
@@ -5,8 +5,8 @@ import { useState } from "react";
import { deleteSamlConfig } from "@/actions/integrations";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Card, CardContent, CardHeader } from "../../shadcn";
@@ -53,8 +53,8 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
return (
<>
{/* Configure SAML Modal */}
<CustomAlertModal
isOpen={isSamlModalOpen}
<Modal
open={isSamlModalOpen}
onOpenChange={setIsSamlModalOpen}
title="Configure SAML SSO"
>
@@ -62,11 +62,11 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
setIsOpen={setIsSamlModalOpen}
samlConfig={samlConfig}
/>
</CustomAlertModal>
</Modal>
{/* Delete Confirmation Modal */}
<CustomAlertModal
isOpen={isDeleteModalOpen}
<Modal
open={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
title="Remove SAML Configuration"
size="md"
@@ -98,7 +98,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
</Button>
</div>
</div>
</CustomAlertModal>
</Modal>
<Card variant="base" padding="lg">
<CardHeader>
@@ -17,8 +17,8 @@ import {
IntegrationSkeleton,
} from "@/components/integrations/shared";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
@@ -259,8 +259,8 @@ export const SecurityHubIntegrationsManager = ({
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Delete Security Hub Integration"
description="This action cannot be undone. This will permanently delete your Security Hub integration."
@@ -293,10 +293,10 @@ export const SecurityHubIntegrationsManager = ({
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</CustomAlertModal>
</Modal>
<CustomAlertModal
isOpen={isModalOpen}
<Modal
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={
editMode === "configuration"
@@ -316,7 +316,7 @@ export const SecurityHubIntegrationsManager = ({
onCancel={handleModalClose}
editMode={editMode}
/>
</CustomAlertModal>
</Modal>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -19,7 +19,7 @@ import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { Modal } from "@/components/shadcn/modal";
import { DeleteForm, EditForm } from "../forms";
@@ -44,8 +44,8 @@ export function DataTableRowActions<InvitationProps>({
return (
<>
<CustomAlertModal
isOpen={isEditOpen}
<Modal
open={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit invitation details"
>
@@ -56,15 +56,15 @@ export function DataTableRowActions<InvitationProps>({
roles={roles || []}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
<CustomAlertModal
isOpen={isDeleteOpen}
</Modal>
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently revoke your invitation."
>
<DeleteForm invitationId={invitationId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
@@ -18,7 +18,7 @@ import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { Modal } from "@/components/shadcn/modal";
import { DeleteGroupForm } from "../forms";
@@ -37,14 +37,14 @@ export function DataTableRowActions<ProviderProps>({
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
>
<DeleteGroupForm groupId={groupId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
+1 -1
View File
@@ -52,7 +52,7 @@ export const ProviderInfo = ({
return (
<div className="flex items-center text-sm">
<div className="flex items-center gap-4">
{getProviderLogo(provider)}
<div className="shrink-0">{getProviderLogo(provider)}</div>
{getIcon()}
<span className="font-medium">{providerAlias || providerUID}</span>
</div>
@@ -20,7 +20,7 @@ import { useState } from "react";
import { checkConnectionProvider } from "@/actions/providers/providers";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { Modal } from "@/components/shadcn/modal";
import { EditForm } from "../forms";
import { DeleteForm } from "../forms/delete-form";
@@ -61,8 +61,8 @@ export function DataTableRowActions<ProviderProps>({
return (
<>
<CustomAlertModal
isOpen={isEditOpen}
<Modal
open={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit Provider Alias"
>
@@ -71,15 +71,15 @@ export function DataTableRowActions<ProviderProps>({
providerAlias={providerAlias}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
<CustomAlertModal
isOpen={isDeleteOpen}
</Modal>
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
>
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
@@ -0,0 +1,48 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ResourceProps } from "@/types";
import { ResourceDetail } from "./table/resource-detail";
interface ResourceDetailsSheetProps {
resource: ResourceProps;
}
export const ResourceDetailsSheet = ({
resource,
}: ResourceDetailsSheetProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleOpenChange = (open: boolean) => {
if (!open) {
const params = new URLSearchParams(searchParams.toString());
params.delete("resourceId");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}
};
return (
<Sheet open={true} onOpenChange={handleOpenChange}>
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetHeader>
<SheetTitle className="sr-only">Resource Details</SheetTitle>
<SheetDescription className="sr-only">
View the resource details
</SheetDescription>
</SheetHeader>
<ResourceDetail resourceDetails={resource} />
</SheetContent>
</Sheet>
);
};
+59 -16
View File
@@ -1,50 +1,93 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { getGroupLabel } from "@/lib/categories";
import { FilterEntity } from "@/types";
import { ProviderProps } from "@/types/providers";
interface ResourcesFiltersProps {
providerIds: string[];
providerDetails: { [id: string]: FilterEntity }[];
providers: ProviderProps[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueGroups: string[];
}
export const ResourcesFilters = ({
providerIds,
providerDetails,
providers,
uniqueRegions,
uniqueServices,
uniqueGroups,
}: ResourcesFiltersProps) => {
return (
<DataTableFilterCustom
filters={[
{
key: "provider__in",
labelCheckboxGroup: "Provider",
values: providerIds,
valueLabelMapping: providerDetails,
},
const [isExpanded, setIsExpanded] = useState(false);
// Custom filters for the expandable section
const customFilters = [
{
key: "region__in",
labelCheckboxGroup: "Region",
values: uniqueRegions,
index: 1,
},
{
key: "service__in",
labelCheckboxGroup: "Service",
values: uniqueServices,
index: 2,
},
{
key: "groups__in",
labelCheckboxGroup: "Resource Group",
labelCheckboxGroup: "Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
index: 3,
},
]}
];
const hasCustomFilters = customFilters.length > 0;
return (
<div className="flex flex-col">
{/* First row: Provider selectors + More Filters button + Clear Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector providers={providers} />
</div>
{hasCustomFilters && (
<Button
variant="outline"
size="lg"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Less Filters" : "More Filters"}
<ChevronDown
className={`size-4 transition-transform duration-300 ${isExpanded ? "rotate-180" : "rotate-0"}`}
/>
</Button>
)}
<ClearFiltersButton showCount />
</div>
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom
filters={customFilters}
prependElement={<CustomDatePicker />}
hideClearButton
/>
</ExpandableSection>
)}
</div>
);
};
@@ -1,39 +1,130 @@
import React from "react";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonTableResources = () => {
const SkeletonTableRow = () => {
return (
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="hidden gap-4 md:flex">
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-1/12" />
<tr className="border-border-neutral-secondary border-b last:border-b-0">
{/* Name - clickable text with copy icon */}
<td className="px-3 py-4">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="size-4 rounded" />
</div>
</td>
{/* Provider Account - logo + alias + uid chip */}
<td className="px-3 py-4">
<div className="flex items-center gap-2">
<Skeleton className="size-9 rounded-xl" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3.5 w-20 rounded" />
<div className="bg-bg-neutral-tertiary flex h-6 w-24 items-center gap-1 rounded-xl px-1.5">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="size-3.5 rounded" />
</div>
</div>
</div>
</td>
{/* Failed Findings - badge */}
<td className="px-3 py-4">
<Skeleton className="h-6 w-10 rounded-full" />
</td>
{/* Group */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-20 rounded" />
</td>
{/* Type */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-24 rounded" />
</td>
{/* Region */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-20 rounded" />
</td>
{/* Service */}
<td className="px-3 py-4">
<Skeleton className="h-4 w-16 rounded" />
</td>
{/* Actions */}
<td className="px-2 py-4">
<Skeleton className="size-6 rounded" />
</td>
</tr>
);
};
export const SkeletonTableResources = () => {
const rows = 10;
return (
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4">
{/* Toolbar: Search + Total entries */}
<div className="flex items-center justify-between">
{/* Search icon button */}
<Skeleton className="size-10 rounded-md" />
{/* Total entries */}
<Skeleton className="h-4 w-28 rounded" />
</div>
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-border-neutral-secondary border-b">
{/* Name */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Provider Account */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-28 rounded" />
</th>
{/* Failed Findings */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-24 rounded" />
</th>
{/* Group */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
{/* Type */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-10 rounded" />
</th>
{/* Region */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Service */}
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
{/* Actions - empty header */}
<th className="w-10 py-3" />
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<SkeletonTableRow key={i} />
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
{/* Rows per page */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-9 w-16 rounded-md" />
</div>
{/* Page info + navigation */}
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-24 rounded" />
<div className="flex gap-1">
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
</div>
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col gap-4 md:flex-row md:items-center"
>
<Skeleton className="h-12 w-full md:w-1/12" />
<Skeleton className="h-12 w-full md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
</div>
))}
</div>
</Card>
);
};
+173 -100
View File
@@ -1,13 +1,18 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Database } from "lucide-react";
import { AlertTriangle, Eye, MoreVertical } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { InfoIcon } from "@/components/icons";
import { EntityInfo, SnippetChip } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { EntityInfo } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { getGroupLabel } from "@/lib/categories";
import { ProviderType, ResourceProps } from "@/types";
import { ResourceDetail } from "./resource-detail";
@@ -19,12 +24,6 @@ const getResourceData = (
return row.original.attributes?.[field];
};
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"],
@@ -35,61 +34,125 @@ const getProviderData = (
);
};
const ResourceDetailsCell = ({ row }: { row: any }) => {
// Component for resource name that opens the detail drawer
const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => {
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-button-primary" size={16} />
}
title="Resource Details"
description="View the Resource details"
defaultOpen={isOpen}
>
<ResourceDetail
resourceId={row.original.id}
initialResourceData={row.original}
/>
</TriggerSheet>
</div>
);
};
export const ColumnResources: ColumnDef<ResourceProps>[] = [
{
id: "moreInfo",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <ResourceDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
const resourceName = row.original.attributes?.name;
const resourceUid = row.original.attributes?.uid;
const displayName =
typeof resourceName === "string" && resourceName.trim().length > 0
? resourceName
: "Unnamed resource";
return (
<SnippetChip
value={displayName}
className="max-w-[320px]"
icon={<Database size={16} />}
<div className="flex items-center gap-2">
<ResourceDetail
resourceDetails={row.original}
defaultOpen={isOpen}
trigger={
<div className="max-w-[200px]">
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
{displayName}
</p>
</div>
}
/>
{resourceUid && <CodeSnippet value={resourceUid} hideCode />}
</div>
);
};
// Component for failed findings badge with warning style
const FailedFindingsBadge = ({ count }: { count: number }) => {
if (count === 0) {
return (
<span className="inline-flex h-6 items-center justify-center rounded-full bg-green-100 px-2 text-xs font-semibold text-green-800 dark:bg-green-900/30 dark:text-green-400">
0
</span>
);
}
return (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-red-100 px-2 text-xs font-semibold text-red-800 dark:bg-red-900/30 dark:text-red-400">
<AlertTriangle className="h-3 w-3" />
{count}
</span>
);
};
// Row actions dropdown
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const resourceName = row.original.attributes?.name || "Resource";
return (
<>
<div className="flex items-center justify-end">
<ActionDropdown
trigger={
<button
type="button"
aria-label="Resource actions"
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<MoreVertical className="text-text-neutral-secondary size-5" />
</button>
}
ariaLabel="Resource actions"
>
<ActionDropdownItem
icon={<Eye className="size-5" />}
label="View details"
description={`View details for ${resourceName}`}
onSelect={() => setIsDrawerOpen(true)}
/>
</ActionDropdown>
</div>
<ResourceDetail
resourceDetails={row.original}
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
trigger={<span className="hidden" />}
/>
</>
);
};
// Column definitions for resources table
export const ColumnResources: ColumnDef<ResourceProps>[] = [
// Name column
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => <ResourceNameCell row={row} />,
enableSorting: false,
},
// Provider Account column
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Provider Account" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
/>
);
},
enableSorting: false,
},
// Failed Findings column
{
accessorKey: "failedFindings",
header: ({ column }) => (
@@ -101,84 +164,94 @@ export const ColumnResources: ColumnDef<ResourceProps>[] = [
"failed_findings_count",
) as number;
return <FailedFindingsBadge count={failedFindingsCount ?? 0} />;
},
enableSorting: false,
},
// Group column
{
accessorKey: "groups",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Group" param="groups" />
),
cell: ({ row }) => {
const groups = getResourceData(row, "groups") as string[] | null;
if (!groups || groups.length === 0) {
return <p className="text-text-neutral-primary text-sm">-</p>;
}
const displayLabel = getGroupLabel(groups[0]);
const extraCount = groups.length - 1;
return (
<span
className={`ml-10 flex h-6 w-6 items-center justify-center rounded-full bg-yellow-100 text-xs font-semibold text-yellow-800 ${getChipStyle(failedFindingsCount)}`}
>
{failedFindingsCount}
<div className="flex items-center gap-1">
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{displayLabel}
</p>
{extraCount > 0 && (
<span className="text-text-neutral-secondary text-xs">
+{extraCount}
</span>
)}
</div>
);
},
enableSorting: false,
},
{
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>
);
},
},
// Type column
{
accessorKey: "type",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Type"} param="type" />
<DataTableColumnHeader column={column} title="Type" param="type" />
),
cell: ({ row }) => {
const type = getResourceData(row, "type");
return (
<div className="max-w-[150px] text-xs break-words whitespace-nowrap">
{typeof type === "string" ? type : "Invalid type"}
</div>
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof type === "string" ? type : "-"}
</p>
);
},
},
// Region column
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" param="region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<p className="text-text-neutral-primary max-w-[120px] truncate text-sm">
{typeof region === "string" ? region : "-"}
</p>
);
},
},
// Service column
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Service"}
param="service"
/>
<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>
<p className="text-text-neutral-primary max-w-[150px] truncate text-sm">
{typeof service === "string" ? service : "-"}
</p>
);
},
},
// Actions column
{
accessorKey: "provider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias && typeof alias === "string" ? alias : undefined}
entityId={uid && typeof uid === "string" ? uid : undefined}
/>
</>
);
},
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => <ResourceRowActions row={row} />,
enableSorting: false,
},
];
+2
View File
@@ -1,3 +1,5 @@
export * from "../skeleton/skeleton-table-resources";
export * from "./column-resources";
export * from "./resource-detail";
export * from "./resource-findings-columns";
export * from "./resources-table-with-selection";
+390 -203
View File
@@ -1,51 +1,53 @@
"use client";
import { Snippet } from "@heroui/snippet";
import { Spinner } from "@heroui/spinner";
import { Tooltip } from "@heroui/tooltip";
import { ExternalLink, InfoIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { getFindingById } from "@/actions/findings";
import { getFindingById, getLatestFindings } from "@/actions/findings";
import { getResourceById } from "@/actions/resources";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
import { FindingDetail } from "@/components/findings/table/finding-detail";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import {
DateWithTime,
getProviderLogo,
InfoField,
} from "@/components/ui/entities";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib";
import { getGroupLabel } from "@/lib/categories";
import { buildGitFileUrl } from "@/lib/iac-utils";
import { FindingProps, ProviderType, ResourceProps } from "@/types";
import {
FindingProps,
MetaDataProps,
ProviderType,
ResourceProps,
} from "@/types";
const SEVERITY_ORDER = {
critical: 0,
high: 1,
medium: 2,
low: 3,
informational: 4,
} as const;
type SeverityLevel = keyof typeof SEVERITY_ORDER;
interface ResourceFinding {
type: "findings";
id: string;
attributes: {
status: "PASS" | "FAIL" | "MANUAL";
severity: SeverityLevel;
check_metadata?: {
checktitle?: string;
};
};
}
interface FindingReference {
id: string;
}
import {
getResourceFindingsColumns,
ResourceFinding,
} from "./resource-findings-columns";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
@@ -97,133 +99,264 @@ const buildCustomBreadcrumbs = (
return breadcrumbs;
};
interface ResourceDetailProps {
resourceDetails: ResourceProps;
trigger?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const ResourceDetail = ({
resourceId,
initialResourceData,
}: {
resourceId: string;
initialResourceData: ResourceProps;
}) => {
resourceDetails,
trigger,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
}: ResourceDetailProps) => {
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
const [findingsMetadata, setFindingsMetadata] =
useState<MetaDataProps | null>(null);
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
const [findingsLoading, setFindingsLoading] = useState(true);
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
null,
);
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
null,
);
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeTab, setActiveTab] = useState("overview");
const [metadataCopied, setMetadataCopied] = useState(false);
// Track internal open state for uncontrolled drawer (when using trigger)
const [internalOpen, setInternalOpen] = useState(defaultOpen);
// Drawer-local pagination and search state (not in URL to avoid page re-renders)
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const findingFetchRef = useRef<AbortController | null>(null);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
findingFetchRef.current?.abort();
};
}, []);
// Determine if drawer is actually open:
// - If controlled (open prop provided), use that
// - If uncontrolled (using trigger), use internal state
// - If no trigger (inline mode), always consider it "open"
const isDrawerOpen = !trigger || (controlledOpen ?? internalOpen);
// Handle open state changes for uncontrolled mode
const handleOpenChange = (newOpen: boolean) => {
setInternalOpen(newOpen);
onOpenChange?.(newOpen);
// Reset all drawer state when closing
if (!newOpen) {
findingFetchRef.current?.abort();
setActiveTab("overview");
setSelectedFindingId(null);
setFindingDetails(null);
setFindingDetailLoading(false);
setRowSelection({});
setCurrentPage(1);
setPageSize(10);
setSearchQuery("");
setHasInitiallyLoaded(false);
}
};
const resource = resourceDetails;
const resourceId = resource.id;
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const copyResourceUrl = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("resourceId", resourceId);
const url = `${window.location.origin}${pathname}?${params.toString()}`;
navigator.clipboard.writeText(url);
};
const copyMetadata = async (metadata: Record<string, unknown>) => {
await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2));
setMetadataCopied(true);
setTimeout(() => setMetadataCopied(false), 2000);
};
// Load resource tags (separate from findings) - only when drawer is open
useEffect(() => {
const loadResourceTags = async () => {
try {
const resourceData = await getResourceById(resourceId, {
fields: ["tags"],
});
if (resourceData?.data) {
setResourceTags(resourceData.data.attributes.tags || {});
}
} catch (err) {
console.error("Error loading resource tags:", err);
setResourceTags({});
}
};
if (resourceId && isDrawerOpen) {
loadResourceTags();
}
}, [resourceId, isDrawerOpen]);
// Load findings with server-side pagination and search - only when drawer is open
useEffect(() => {
const loadFindings = async () => {
setFindingsLoading(true);
try {
const resourceData = await getResourceById(resourceId, {
include: ["findings"],
fields: ["tags", "findings"],
const findingsResponse = await getLatestFindings({
page: currentPage,
pageSize,
query: searchQuery,
sort: "severity,-inserted_at",
filters: {
"filter[resource_uid]": attributes.uid,
"filter[status]": "FAIL",
},
});
if (resourceData?.data) {
// Get tags from the detailed resource data
setResourceTags(resourceData.data.attributes.tags || {});
// Create dictionary for findings and expand them
if (resourceData.data.relationships?.findings) {
const findingsDict = createDict("findings", resourceData);
const findings =
resourceData.data.relationships.findings.data?.map(
(finding: FindingReference) => findingsDict[finding.id],
) || [];
setFindingsData(findings as ResourceFinding[]);
if (findingsResponse?.data) {
setFindingsMetadata(findingsResponse.meta || null);
setFindingsData(findingsResponse.data as ResourceFinding[]);
} else {
setFindingsData([]);
}
} else {
setFindingsData([]);
setResourceTags({});
setFindingsMetadata(null);
}
} catch (err) {
console.error("Error loading findings:", err);
setFindingsData([]);
setResourceTags({});
setFindingsMetadata(null);
} finally {
setFindingsLoading(false);
setHasInitiallyLoaded(true);
}
};
if (resourceId) {
if (attributes.uid && isDrawerOpen) {
loadFindings();
}
}, [resourceId]);
}, [
attributes.uid,
currentPage,
pageSize,
searchQuery,
isDrawerOpen,
findingsReloadNonce,
]);
const navigateToFinding = async (findingId: string) => {
// Cancel any in-flight request
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
findingFetchRef.current = new AbortController();
setSelectedFindingId(findingId);
setFindingDetailLoading(true);
try {
const findingData = await getFindingById(
findingId,
"resources,scan.provider",
);
// Check if request was aborted
if (findingFetchRef.current?.signal.aborted) {
return;
}
if (findingData?.data) {
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingData);
const scanDict = createDict("scans", findingData);
const providerDict = createDict("providers", findingData);
// Expand the finding with its corresponding resource, scan, and provider
const finding = findingData.data;
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
const foundResource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider = providerDict[scan?.relationships?.provider?.data?.id];
const expandedFinding = {
...finding,
relationships: { scan, resource, provider },
relationships: { scan, resource: foundResource, provider },
};
setFindingDetails(expandedFinding);
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Error fetching finding:", error);
} finally {
// Only update loading state if this request wasn't aborted
if (!findingFetchRef.current?.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const handleBackToResource = () => {
setSelectedFindingId(null);
setFindingDetails(null);
setFindingDetailLoading(false);
};
if (!initialResourceData) {
return (
<div className="flex min-h-96 flex-col items-center justify-center gap-4 rounded-lg p-8">
<Spinner size="lg" />
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
Loading resource details...
</p>
</div>
);
}
const handleMuteComplete = (_findingIds?: string[]) => {
const ids =
_findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds;
const resource = initialResourceData;
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
setRowSelection({});
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
router.refresh();
};
// Filter only failed findings and sort by severity
const failedFindings = findingsData
.filter(
(finding: ResourceFinding) => finding?.attributes?.status === "FAIL",
)
.sort((a: ResourceFinding, b: ResourceFinding) => {
const severityA = (a?.attributes?.severity?.toLowerCase() ||
"informational") as SeverityLevel;
const severityB = (b?.attributes?.severity?.toLowerCase() ||
"informational") as SeverityLevel;
return (
(SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999)
// Findings are already filtered (FAIL only) and sorted by the server
const failedFindings = findingsData;
const selectableRowCount = failedFindings.filter(
(f) => !f.attributes.muted,
).length;
// Reset selection when page changes
useEffect(() => {
setRowSelection({});
}, [currentPage, pageSize]);
// Calculate total findings from metadata for tab title
const totalFindings = findingsMetadata?.pagination?.count || 0;
const getRowCanSelect = (row: Row<ResourceFinding>): boolean =>
!row.original.attributes.muted;
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => failedFindings[parseInt(idx)]?.id)
.filter(Boolean);
const columns = getResourceFindingsColumns(
rowSelection,
selectableRowCount,
navigateToFinding,
handleMuteComplete,
);
});
// Build Git URL for IaC resources
const gitUrl =
@@ -236,86 +369,134 @@ export const ResourceDetail = ({
)
: null;
if (selectedFindingId) {
// Content when viewing a finding detail (breadcrumb navigation)
const findingTitle =
findingDetails?.attributes?.check_metadata?.checktitle ||
"Finding Detail";
findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail";
return (
const findingContent = (
<div className="flex flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={buildCustomBreadcrumbs(
attributes.name,
findingTitle,
findingDetailLoading ? "Loading..." : findingTitle,
handleBackToResource,
)}
/>
{findingDetails && <FindingDetail findingDetails={findingDetails} />}
{findingDetailLoading ? (
<div className="flex items-center justify-center gap-2 py-8">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-text-neutral-secondary text-sm">
Loading finding details...
</p>
</div>
) : (
findingDetails && <FindingDetail findingDetails={findingDetails} />
)}
</div>
);
}
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Resource Details section */}
<Card variant="base" padding="lg">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center justify-start gap-2">
<CardTitle>Resource Details</CardTitle>
// Main resource content
const resourceContent = (
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
{/* Header */}
<div className="flex items-center gap-4">
{/* Provider logo */}
<div className="shrink-0">
{getProviderLogo(providerData.provider as ProviderType)}
</div>
{/* Details column */}
<div className="flex min-w-0 flex-col gap-1">
{/* Title with copy link and optional IaC link */}
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{renderValue(attributes.name)}
</h2>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={copyResourceUrl}
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
aria-label="Copy resource link to clipboard"
>
<Link size={16} />
</button>
</TooltipTrigger>
<TooltipContent>Copy resource link to clipboard</TooltipContent>
</Tooltip>
{providerData.provider === "iac" && gitUrl && (
<Tooltip content="Go to Resource in the Repository" size="sm">
<Tooltip>
<TooltipTrigger asChild>
<a
href={gitUrl}
target="_blank"
rel="noopener noreferrer"
className="text-bg-data-info mt-1 inline-flex cursor-pointer"
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
aria-label="Open resource in repository"
>
<ExternalLink size={16} className="inline" />
<ExternalLink size={16} />
View in Repository
</a>
</TooltipTrigger>
<TooltipContent>
Go to Resource in the Repository
</TooltipContent>
</Tooltip>
)}
</div>
{getProviderLogo(providerData.provider as ProviderType)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoField label="Resource UID" variant="simple">
<Snippet
className="border-border-neutral-tertiary bg-bg-neutral-tertiary rounded-lg border py-1"
hideSymbol
>
<span className="text-xs whitespace-pre-line">
{renderValue(attributes.uid)}
{/* Last Updated */}
<div className="text-text-neutral-tertiary text-sm">
<span className="text-text-neutral-secondary mr-1">
Last Updated:
</span>
</Snippet>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="findings">
Findings {totalFindings > 0 && `(${totalFindings})`}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="flex flex-col gap-4">
<InfoField label="Resource UID" variant="simple">
<CodeSnippet value={attributes.uid} className="max-w-full" />
</InfoField>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Resource Name">
{renderValue(attributes.name)}
</InfoField>
<InfoField label="Resource Type">
{renderValue(attributes.type)}
</InfoField>
<InfoField label="Name">{renderValue(attributes.name)}</InfoField>
<InfoField label="Type">{renderValue(attributes.type)}</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Group">
{attributes.groups && attributes.groups.length > 0
? attributes.groups.map(getGroupLabel).join(", ")
: "-"}
</InfoField>
<InfoField label="Service">
{renderValue(attributes.service)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Region">
{renderValue(attributes.region)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Partition">
{renderValue(attributes.partition)}
</InfoField>
<InfoField label="Details">
</div>
<InfoField label="Details" variant="simple">
{renderValue(attributes.details)}
</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Created At">
<DateWithTime inline dateTime={attributes.inserted_at} />
@@ -331,16 +512,19 @@ export const ResourceDetail = ({
Object.entries(parsedMetadata).length > 0 ? (
<InfoField label="Metadata" variant="simple">
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
<Snippet
className="absolute top-2 right-2 z-10 bg-transparent"
classNames={{
base: "bg-transparent p-0 min-w-0",
pre: "hidden",
}}
<button
type="button"
onClick={() => copyMetadata(parsedMetadata)}
className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors"
aria-label="Copy metadata to clipboard"
>
{JSON.stringify(parsedMetadata, null, 2)}
</Snippet>
<pre className="minimal-scrollbar mr-10 max-h-[100px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{metadataCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
<pre className="minimal-scrollbar mr-10 max-h-[200px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(parsedMetadata, null, 2)}
</pre>
</div>
@@ -350,7 +534,7 @@ export const ResourceDetail = ({
{resourceTags && Object.entries(resourceTags).length > 0 ? (
<div className="flex flex-col gap-4">
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
<h4 className="text-text-neutral-secondary text-sm font-bold">
Tags
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -362,79 +546,82 @@ export const ResourceDetail = ({
</div>
</div>
) : null}
</CardContent>
</Card>
</TabsContent>
{/* Failed findings associated with this resource section */}
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Failed findings associated with this resource</CardTitle>
</CardHeader>
<CardContent>
{findingsLoading ? (
{/* Findings Tab */}
<TabsContent value="findings" className="flex flex-col gap-4">
{findingsLoading && !hasInitiallyLoaded ? (
<div className="flex items-center justify-center gap-2 py-8">
<Spinner size="sm" />
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-text-neutral-secondary text-sm">
Loading findings...
</p>
</div>
) : failedFindings.length > 0 ? (
<div className="flex flex-col gap-4">
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
Total failed findings: {failedFindings.length}
</p>
{failedFindings.map((finding: ResourceFinding, index: number) => {
const { attributes: findingAttrs, id } = finding;
// Handle cases where finding might not have all attributes
if (!findingAttrs) {
return (
<div
key={index}
className="shadow-small dark:bg-prowler-blue-400 flex flex-col gap-2 rounded-lg px-4 py-2"
>
<p className="text-sm text-red-600">
Finding {id} - No attributes available
</p>
) : (
<>
<DataTable
columns={columns}
data={failedFindings}
metadata={findingsMetadata ?? undefined}
showSearch
disableScroll
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
controlledSearch={searchQuery}
onSearchChange={(value) => {
setSearchQuery(value);
setCurrentPage(1); // Reset to first page on search
}}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
);
// Determine which content to show
const content = selectedFindingId ? findingContent : resourceContent;
// If no trigger, render content directly (inline mode)
if (!trigger) {
return content;
}
const { severity, check_metadata, status } = findingAttrs;
const checktitle =
check_metadata?.checktitle || "Unknown check";
// With trigger, wrap in Drawer
return (
<button
key={index}
onClick={() => navigateToFinding(id)}
className="shadow-small border-border-neutral-tertiary bg-bg-neutral-tertiary flex w-full cursor-pointer flex-col gap-2 rounded-lg px-4 py-2"
<Drawer
direction="right"
open={controlledOpen ?? internalOpen}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
>
<div className="flex items-center justify-between gap-2">
<h3 className="dark:text-prowler-theme-pale/90 text-left text-sm font-medium text-gray-800">
{checktitle}
</h3>
<div className="flex items-center gap-2">
<SeverityBadge severity={severity || "-"} />
<StatusFindingBadge status={status || "-"} />
<InfoIcon
className="text-button-primary cursor-pointer"
size={16}
onClick={() => navigateToFinding(id)}
/>
</div>
</div>
</button>
);
})}
</div>
) : (
<p className="dark:text-prowler-theme-pale/80 text-gray-600">
No failed findings found for this resource.
</p>
)}
</CardContent>
</Card>
</div>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 outline-none md:w-1/2 md:max-w-none">
<DrawerHeader className="sr-only">
<DrawerTitle>Resource Details</DrawerTitle>
<DrawerDescription>View the resource details</DrawerDescription>
</DrawerHeader>
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DrawerClose>
{content}
</DrawerContent>
</Drawer>
);
};
@@ -0,0 +1,146 @@
"use client";
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTableRowActions } from "@/components/findings/table";
import {
DeltaType,
NotificationIndicator,
} from "@/components/findings/table/notification-indicator";
import { Checkbox } from "@/components/shadcn";
import { DateWithTime } from "@/components/ui/entities";
import {
DataTableColumnHeader,
Severity,
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
export interface ResourceFinding {
type: "findings";
id: string;
attributes: {
status: "PASS" | "FAIL" | "MANUAL";
severity: Severity;
muted?: boolean;
muted_reason?: string;
delta?: DeltaType;
updated_at?: string;
check_metadata?: {
checktitle?: string;
};
};
}
export const getResourceFindingsColumns = (
rowSelection: RowSelectionState,
selectableRowCount: number,
onNavigate: (id: string) => void,
onMuteComplete?: (findingIds: string[]) => void,
): ColumnDef<ResourceFinding>[] => {
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
selectedCount > 0 && selectedCount === selectableRowCount;
const isSomeSelected =
selectedCount > 0 && selectedCount < selectableRowCount;
return [
{
id: "notification",
header: () => null,
cell: ({ row }) => (
<div className="flex items-center justify-center pr-4">
<NotificationIndicator
delta={row.original.attributes.delta}
isMuted={row.original.attributes.muted}
mutedReason={row.original.attributes.muted_reason}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: ({ table }) => (
<div className="ml-1 flex w-6 items-center justify-center pr-4">
<Checkbox
checked={
isAllSelected ? true : isSomeSelected ? "indeterminate" : false
}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
aria-label="Select all"
disabled={selectableRowCount === 0}
/>
</div>
),
cell: ({ row }) => (
<div className="ml-1 flex w-6 items-center justify-center pr-4">
<Checkbox
checked={!!rowSelection[row.id]}
disabled={row.original.attributes.muted}
onCheckedChange={(checked) => row.toggleSelected(checked === true)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => (
<StatusFindingBadge status={row.original.attributes.status || "-"} />
),
enableSorting: false,
},
{
accessorKey: "finding",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Finding" />
),
cell: ({ row }) => (
<button
onClick={() => onNavigate(row.original.id)}
className="text-text-neutral-primary hover:text-button-tertiary max-w-[300px] cursor-pointer truncate text-left text-sm hover:underline"
>
{row.original.attributes.check_metadata?.checktitle ||
"Unknown check"}
</button>
),
enableSorting: false,
},
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Severity" />
),
cell: ({ row }) => (
<SeverityBadge severity={row.original.attributes.severity || "-"} />
),
enableSorting: false,
},
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Time" />
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.updated_at || "-"} />
),
enableSorting: false,
},
{
id: "actions",
header: () => <div className="w-10" />,
cell: ({ row }) => (
<DataTableRowActions row={row} onMuteComplete={onMuteComplete} />
),
enableSorting: false,
},
];
};
@@ -0,0 +1,28 @@
"use client";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps, ResourceProps } from "@/types";
import { ColumnResources } from "./column-resources";
interface ResourcesTableWithSelectionProps {
data: ResourceProps[];
metadata?: MetaDataProps;
}
export function ResourcesTableWithSelection({
data,
metadata,
}: ResourcesTableWithSelectionProps) {
// Ensure data is always an array for safe operations
const safeData = data ?? [];
return (
<DataTable
columns={ColumnResources}
data={safeData}
metadata={metadata}
showSearch
/>
);
}
@@ -18,7 +18,7 @@ import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Modal } from "@/components/shadcn/modal";
import { DeleteRoleForm } from "../workflow/forms";
interface DataTableRowActionsProps<RoleProps> {
@@ -34,14 +34,14 @@ export function DataTableRowActions<RoleProps>({
const roleId = (row.original as { id: string }).id;
return (
<>
<CustomAlertModal
isOpen={isDeleteOpen}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your role and remove your data from the server."
>
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
@@ -17,8 +17,8 @@ import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { downloadScanZip } from "@/lib/helper";
import { EditScanForm } from "../../forms";
@@ -39,8 +39,8 @@ export function DataTableRowActions<ScanProps>({
return (
<>
<CustomAlertModal
isOpen={isEditOpen}
<Modal
open={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit Scan Name"
>
@@ -49,7 +49,7 @@ export function DataTableRowActions<ScanProps>({
scanName={scanName}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
@@ -0,0 +1,131 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { ComponentProps, ReactNode } from "react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./dropdown";
interface ActionDropdownProps {
/** The dropdown trigger element. Defaults to a vertical dots icon button */
trigger?: ReactNode;
/** Label shown at the top of the dropdown */
label?: string;
/** Alignment of the dropdown content */
align?: "start" | "center" | "end";
/** Additional className for the content */
className?: string;
/** Accessible label for the trigger */
ariaLabel?: string;
children: ReactNode;
}
export function ActionDropdown({
trigger,
label = "Actions",
align = "end",
className,
ariaLabel = "Open actions menu",
children,
}: ActionDropdownProps) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
{trigger ?? (
<button
type="button"
aria-label={ariaLabel}
className="hover:bg-bg-neutral-tertiary rounded-md p-1 transition-colors"
>
<MoreHorizontal className="text-text-neutral-secondary size-5" />
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary w-56",
className,
)}
>
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface ActionDropdownItemProps
extends Omit<ComponentProps<typeof DropdownMenuItem>, "children"> {
/** Icon displayed before the label */
icon?: ReactNode;
/** Main label text */
label: ReactNode;
/** Optional description text below the label */
description?: string;
/** Whether the item is destructive (danger styling) */
destructive?: boolean;
}
export function ActionDropdownItem({
icon,
label,
description,
destructive = false,
className,
...props
}: ActionDropdownItemProps) {
return (
<DropdownMenuItem
className={cn(
"flex cursor-pointer items-center gap-2",
destructive && "text-destructive focus:text-destructive",
className,
)}
{...props}
>
{icon && (
<span
className={cn(
"text-muted-foreground shrink-0 [&>svg]:size-5",
destructive && "text-destructive",
)}
>
{icon}
</span>
)}
<div className="flex flex-col">
<span>{label}</span>
{description && (
<span
className={cn(
"text-muted-foreground text-xs",
destructive && "text-destructive/70",
)}
>
{description}
</span>
)}
</div>
</DropdownMenuItem>
);
}
// Re-export commonly used components for convenience
export {
DropdownMenuLabel as ActionDropdownLabel,
DropdownMenuSeparator as ActionDropdownSeparator,
} from "./dropdown";
+23
View File
@@ -0,0 +1,23 @@
export {
ActionDropdown,
ActionDropdownItem,
ActionDropdownLabel,
ActionDropdownSeparator,
} from "./action-dropdown";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown";
+1
View File
@@ -0,0 +1 @@
export { Modal } from "./modal";
+67
View File
@@ -0,0 +1,67 @@
import { ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/shadcn/dialog";
import { cn } from "@/lib/utils";
const SIZE_CLASSES = {
sm: "sm:max-w-sm",
md: "sm:max-w-md",
lg: "sm:max-w-lg",
xl: "sm:max-w-xl",
"2xl": "sm:max-w-2xl",
"3xl": "sm:max-w-3xl",
"4xl": "sm:max-w-4xl",
"5xl": "sm:max-w-5xl",
} as const;
type ModalSize = keyof typeof SIZE_CLASSES;
interface ModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: string;
children: ReactNode;
size?: ModalSize;
className?: string;
}
export const Modal = ({
open,
onOpenChange,
title,
description,
children,
size = "xl",
className,
}: ModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary",
SIZE_CLASSES[size],
className,
)}
>
{title && (
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription className="text-small text-gray-600 dark:text-gray-300">
{description}
</DialogDescription>
)}
</DialogHeader>
)}
{children}
</DialogContent>
</Dialog>
);
};
+97 -11
View File
@@ -1,13 +1,99 @@
import { Snippet } from "@heroui/snippet";
"use client";
export const CodeSnippet = ({ value }: { value: string }) => (
<Snippet
className="bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary w-full rounded-lg border py-1 text-xs"
hideSymbol
classNames={{
pre: "w-full truncate",
}}
import { Check, Copy } from "lucide-react";
import { ReactNode, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
interface CodeSnippetProps {
value: string;
className?: string;
/** Hide the code text and show only the copy button */
hideCode?: boolean;
/** Hide the copy button */
hideCopyButton?: boolean;
/** Icon to display before the text */
icon?: ReactNode;
/** Function to format the displayed text (value is still copied as-is) */
formatter?: (value: string) => string;
}
export const CodeSnippet = ({
value,
className,
hideCode = false,
hideCopyButton = false,
icon,
formatter,
}: CodeSnippetProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const displayValue = formatter ? formatter(value) : value;
const CopyButton = () => (
<button
type="button"
onClick={handleCopy}
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer transition-colors"
aria-label="Copy to clipboard"
>
{value}
</Snippet>
);
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
// When hideCode is true, render only the copy button without container styling
if (hideCode) {
return (
<button
type="button"
onClick={handleCopy}
className={cn(
"hover:bg-bg-neutral-tertiary text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer rounded-md p-1 transition-colors",
className,
)}
aria-label="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
}
return (
<div
className={cn(
"bg-bg-neutral-tertiary text-text-neutral-primary border-border-neutral-tertiary flex h-6 w-fit items-center gap-2 rounded-lg border px-2 py-1 text-xs",
className,
)}
>
{icon && (
<span className="text-text-neutral-secondary shrink-0">{icon}</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<code className="min-w-0 flex-1 truncate">{displayValue}</code>
</TooltipTrigger>
<TooltipContent side="top">{value}</TooltipContent>
</Tooltip>
{!hideCopyButton && <CopyButton />}
</div>
);
};
@@ -1,50 +0,0 @@
import { Modal, ModalBody, ModalContent, ModalHeader } from "@heroui/modal";
import React, { ReactNode } from "react";
interface CustomAlertModalProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
title?: string;
description?: string;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
}
export const CustomAlertModal: React.FC<CustomAlertModalProps> = ({
isOpen,
onOpenChange,
title,
description,
children,
size = "xl",
}) => {
return (
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
size={size}
classNames={{
base: "border border-border-neutral-secondary bg-bg-neutral-secondary",
closeButton: "rounded-md",
}}
backdrop="blur"
placement="center"
>
<ModalContent className="py-4">
{(_onClose) => (
<>
<ModalHeader className="flex flex-col py-0">{title}</ModalHeader>
<ModalBody>
{description && (
<p className="text-small text-gray-600 dark:text-gray-300">
{description}
</p>
)}
{children}
</ModalBody>
</>
)}
</ModalContent>
</Modal>
);
};
-1
View File
@@ -1,4 +1,3 @@
export * from "./custom-alert-modal";
export * from "./custom-banner";
export * from "./custom-dropdown-selection";
export * from "./custom-input";
+26 -52
View File
@@ -1,9 +1,11 @@
"use client";
import { Tooltip } from "@heroui/tooltip";
import { useEffect, useState } from "react";
import { CopyIcon, DoneIcon } from "@/components/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import type { ProviderType } from "@/types";
import { getProviderLogo } from "./get-provider-logo";
@@ -26,26 +28,6 @@ export const EntityInfo = ({
maxWidth = "w-[120px]",
showCopyAction = true,
}: EntityInfoProps) => {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return undefined;
const timer = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(timer);
}, [copied]);
const handleCopyEntityId = async () => {
if (!entityId) return;
try {
await navigator.clipboard.writeText(entityId);
setCopied(true);
} catch (_error) {
setCopied(false);
}
};
const canCopy = Boolean(entityId && showCopyAction);
return (
@@ -53,54 +35,46 @@ export const EntityInfo = ({
<div className="relative shrink-0">
{getProviderLogo(cloudProvider)}
{showConnectionStatus && (
<Tooltip
size="sm"
content={showConnectionStatus ? "Connected" : "Not Connected"}
>
<span
className={`absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full ${
showConnectionStatus ? "bg-green-500" : "bg-red-500"
}`}
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="absolute top-[-0.1rem] right-[-0.2rem] h-2 w-2 cursor-pointer rounded-full bg-green-500" />
</TooltipTrigger>
<TooltipContent>Connected</TooltipContent>
</Tooltip>
)}
</div>
<div className={`flex ${maxWidth} flex-col gap-1`}>
{entityAlias ? (
<Tooltip content={entityAlias} placement="top-start" size="sm">
<Tooltip>
<TooltipTrigger asChild>
<p className="text-text-neutral-primary truncate text-left text-xs font-medium">
{entityAlias}
</p>
</TooltipTrigger>
<TooltipContent side="top">{entityAlias}</TooltipContent>
</Tooltip>
) : (
<Tooltip content="No alias" placement="top-start" size="sm">
<Tooltip>
<TooltipTrigger asChild>
<p className="text-text-neutral-secondary truncate text-left text-xs">
-
</p>
</TooltipTrigger>
<TooltipContent side="top">No alias</TooltipContent>
</Tooltip>
)}
{entityId && (
<div className="flex min-w-0 items-center gap-1">
<Tooltip content={entityId} placement="top-start" size="sm">
<p className="text-text-neutral-secondary min-w-0 truncate text-left text-xs">
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex w-full min-w-0 items-center gap-1 rounded-xl border px-1.5">
<Tooltip>
<TooltipTrigger asChild>
<p className="text-text-neutral-secondary min-w-0 flex-1 truncate text-left text-xs">
{entityId}
</p>
</TooltipTrigger>
<TooltipContent side="top">{entityId}</TooltipContent>
</Tooltip>
{canCopy && (
<Tooltip
content={copied ? "Copied" : "Copy to clipboard"}
placement="top"
size="sm"
>
<button
type="button"
onClick={handleCopyEntityId}
aria-label="Copiar ID de la entidad"
className="hover:bg-bg-neutral-tertiary focus-visible:ring-bg-data-info text-text-neutral-secondary hover:text-text-neutral-primary rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{copied ? <DoneIcon size={14} /> : <CopyIcon size={14} />}
</button>
</Tooltip>
<CodeSnippet value={entityId} hideCode className="shrink-0" />
)}
</div>
)}
+6 -3
View File
@@ -34,7 +34,10 @@ export const MenuItem = ({
highlight,
}: MenuItemProps) => {
const pathname = usePathname();
const isActive = active !== undefined ? active : pathname.startsWith(href);
// Extract only the pathname from href (without query parameters) for comparison
const hrefPathname = href.split("?")[0];
const isActive =
active !== undefined ? active : pathname.startsWith(hrefPathname);
// Show tooltip always for Prowler Hub, or when sidebar is collapsed
const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
@@ -55,8 +58,8 @@ export const MenuItem = ({
<Icon size={18} />
</span>
{isOpen && (
<p className="max-w-[200px] truncate">
{label}
<p className="flex max-w-[200px] items-center truncate">
<span>{label}</span>
{highlight && (
<span className="ml-2 rounded-sm bg-emerald-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
NEW
@@ -24,6 +24,18 @@ import { MetaDataProps } from "@/types";
interface DataTablePaginationProps {
metadata?: MetaDataProps;
disableScroll?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled mode: Use these props to manage pagination via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when paginating.
*/
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
const NAV_BUTTON_STYLES = {
@@ -35,18 +47,44 @@ const NAV_BUTTON_STYLES = {
export function DataTablePagination({
metadata,
disableScroll = false,
paramPrefix = "",
controlledPage,
controlledPageSize,
onPageChange,
onPageSizeChange,
}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const initialPageSize = searchParams.get("pageSize") ?? "50";
// Determine if we're in controlled mode
const isControlled = controlledPage !== undefined && onPageChange;
// Determine param names based on prefix
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
const initialPageSize = isControlled
? String(controlledPageSize ?? 10)
: (searchParams.get(pageSizeParam) ?? "10");
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
if (!metadata) return null;
const { currentPage, totalPages, totalEntries, itemsPerPageOptions } =
getPaginationInfo(metadata);
const {
currentPage: metaCurrentPage,
totalPages,
totalEntries,
itemsPerPageOptions,
} = getPaginationInfo(metadata);
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
const currentPage = isControlled
? controlledPage
: paramPrefix
? parseInt(searchParams.get(pageParam) || "1", 10)
: metaCurrentPage;
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
@@ -60,7 +98,7 @@ export function DataTablePagination({
return `${pathname}?${params.toString()}`;
}
params.set("page", pageNumber.toString());
params.set(pageParam, pageNumber.toString());
// Ensure that scanId, id and version are preserved
if (scanId) params.set("scanId", scanId);
@@ -70,6 +108,20 @@ export function DataTablePagination({
return `${pathname}?${params.toString()}`;
};
// Handle page navigation for controlled mode
const handlePageChange = (pageNumber: number) => {
if (isControlled) {
onPageChange(pageNumber);
} else {
const url = createPageUrl(pageNumber);
if (disableScroll) {
router.push(url, { scroll: false });
} else {
router.push(url);
}
}
};
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
@@ -87,6 +139,12 @@ export function DataTablePagination({
onValueChange={(value) => {
setSelectedPageSize(value);
if (isControlled) {
onPageSizeChange?.(parseInt(value, 10));
onPageChange(1); // Reset to first page
return;
}
const params = new URLSearchParams(searchParams);
// Preserve all important parameters
@@ -94,8 +152,8 @@ export function DataTablePagination({
const id = searchParams.get("id");
const version = searchParams.get("version");
params.set("pageSize", value);
params.set("page", "1");
params.set(pageSizeParam, value);
params.set(pageParam, "1");
// Ensure that scanId, id and version are preserved
if (scanId) params.set("scanId", scanId);
@@ -137,6 +195,67 @@ export function DataTablePagination({
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-3">
{isControlled ? (
<>
<button
type="button"
aria-label="Go to first page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(1)}
>
<ChevronFirst className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to previous page"
className={cn(
NAV_BUTTON_STYLES.base,
isFirstPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isFirstPage}
onClick={() => handlePageChange(currentPage - 1)}
>
<ChevronLeft className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to next page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(currentPage + 1)}
>
<ChevronRight className="size-6" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Go to last page"
className={cn(
NAV_BUTTON_STYLES.base,
isLastPage
? NAV_BUTTON_STYLES.disabled
: NAV_BUTTON_STYLES.enabled,
)}
disabled={isLastPage}
onClick={() => handlePageChange(totalPages)}
>
<ChevronLast className="size-6" aria-hidden="true" />
</button>
</>
) : (
<>
<Link
aria-label="Go to first page"
className={cn(
@@ -213,6 +332,8 @@ export function DataTablePagination({
>
<ChevronLast className="size-6" aria-hidden="true" />
</Link>
</>
)}
</div>
</div>
</>
+81 -9
View File
@@ -1,19 +1,40 @@
"use client";
import { LoaderCircleIcon, SearchIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useId, useRef, useState } from "react";
import { Input } from "@/components/shadcn/input/input";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { cn } from "@/lib/utils";
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_DEBOUNCE_MS = 500;
export const DataTableSearch = () => {
interface DataTableSearchProps {
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */
paramPrefix?: string;
/*
* Controlled mode: Use these props to manage search via React state
* instead of URL params. Useful for tables in drawers/modals to avoid
* triggering page re-renders when searching.
*/
controlledValue?: string;
onSearchChange?: (value: string) => void;
}
export const DataTableSearch = ({
paramPrefix = "",
controlledValue,
onSearchChange,
}: DataTableSearchProps) => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const { updateFilter } = useUrlFilters();
const [value, setValue] = useState("");
const [internalValue, setInternalValue] = useState("");
// In controlled mode, track display value separately for immediate feedback
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isFocused, setIsFocused] = useState(false);
@@ -21,27 +42,77 @@ export const DataTableSearch = () => {
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Use controlled value if provided, otherwise internal state
const isControlled = controlledValue !== undefined && onSearchChange;
// For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise
const value = isControlled ? displayValue : internalValue;
// Sync displayValue when controlledValue changes externally (e.g., clear filters)
useEffect(() => {
if (isControlled) {
setDisplayValue(controlledValue);
}
}, [controlledValue, isControlled]);
// Determine param names based on prefix
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
// Keep expanded if there's a value or input is focused
const shouldStayExpanded = value.length > 0 || isFocused;
// Sync with URL on mount
// Sync with URL on mount (only for uncontrolled mode)
useEffect(() => {
const searchFromUrl = searchParams.get("filter[search]") || "";
setValue(searchFromUrl);
if (isControlled) return;
const searchFromUrl = searchParams.get(searchParam) || "";
setInternalValue(searchFromUrl);
// If there's a search value, start expanded
if (searchFromUrl) {
setIsExpanded(true);
}
}, [searchParams]);
}, [searchParams, searchParam, isControlled]);
// Handle input change with debounce
const handleChange = (newValue: string) => {
setValue(newValue);
// For controlled mode, update display immediately, debounce the callback
if (isControlled) {
// Update display value immediately for responsive typing
setDisplayValue(newValue);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
onSearchChange(newValue);
setIsLoading(false);
}, SEARCH_DEBOUNCE_MS);
return;
}
setInternalValue(newValue);
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// If using prefix, handle URL updates directly instead of useUrlFilters
if (paramPrefix) {
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (newValue) {
params.set(searchParam, newValue);
} else {
params.delete(searchParam);
}
params.set(pageParam, "1"); // Reset to first page
router.push(`${pathname}?${params.toString()}`, { scroll: false });
setIsLoading(false);
}, SEARCH_DEBOUNCE_MS);
} else {
// Original behavior for non-prefixed search
if (newValue) {
setIsLoading(true);
debounceTimeoutRef.current = setTimeout(() => {
@@ -52,6 +123,7 @@ export const DataTableSearch = () => {
setIsLoading(false);
updateFilter("search", null);
}
}
};
// Cleanup timeout on unmount
+74 -8
View File
@@ -6,7 +6,6 @@ import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
OnChangeFn,
Row,
@@ -26,6 +25,8 @@ import {
} from "@/components/ui/table";
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import { useFilterTransitionOptional } from "@/contexts";
import { cn } from "@/lib";
import { FilterOption, MetaDataProps } from "@/types";
interface DataTableProviderProps<TData, TValue> {
@@ -41,6 +42,39 @@ interface DataTableProviderProps<TData, TValue> {
getRowCanSelect?: (row: Row<TData>) => boolean;
/** Show search bar in the table toolbar */
showSearch?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
/*
* Controlled Mode Props
* ---------------------
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
* This causes Next.js page re-renders on every interaction.
*
* For tables inside drawers/modals, use controlled mode instead:
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
*
* Example:
* const [page, setPage] = useState(1);
* const [search, setSearch] = useState("");
* <DataTable
* controlledPage={page}
* onPageChange={setPage}
* controlledSearch={search}
* onSearchChange={setSearch}
* isLoading={isLoading}
* />
*/
controlledSearch?: string;
onSearchChange?: (value: string) => void;
controlledPage?: number;
controlledPageSize?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
/** Show loading state with opacity overlay (for controlled mode) */
isLoading?: boolean;
}
export function DataTable<TData, TValue>({
@@ -53,18 +87,29 @@ export function DataTable<TData, TValue>({
onRowSelectionChange,
getRowCanSelect,
showSearch = false,
paramPrefix = "",
controlledSearch,
onSearchChange,
controlledPage,
controlledPageSize,
onPageChange,
onPageSizeChange,
isLoading = false,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// Get transition state from context for loading indicator
const filterTransition = useFilterTransitionOptional();
// Use either context-based pending state or controlled isLoading prop
const isPending = (filterTransition?.isPending ?? false) || isLoading;
const table = useReactTable({
data,
columns,
enableSorting: true,
// Use getRowCanSelect function if provided, otherwise use boolean
enableRowSelection: getRowCanSelect ?? enableRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
@@ -86,13 +131,29 @@ export function DataTable<TData, TValue>({
// Format total entries count
const totalEntries = metadata?.pagination?.count ?? 0;
const formattedTotal = totalEntries.toLocaleString();
const showToolbar = showSearch || metadata;
const rows = table.getRowModel().rows;
return (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4">
<div
className={cn(
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4 transition-opacity duration-200",
isPending && "pointer-events-none opacity-60",
)}
>
{/* Table Toolbar */}
{(showSearch || metadata) && (
{showToolbar && (
<div className="flex items-center justify-between">
<div>{showSearch && <DataTableSearch />}</div>
<div>
{showSearch && (
<DataTableSearch
paramPrefix={paramPrefix}
controlledValue={controlledSearch}
onSearchChange={onSearchChange}
/>
)}
</div>
{metadata && (
<span className="text-text-neutral-secondary text-sm">
{formattedTotal} Total Entries
@@ -120,8 +181,8 @@ export function DataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{rows?.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
@@ -146,6 +207,11 @@ export function DataTable<TData, TValue>({
<DataTablePagination
metadata={metadata}
disableScroll={disableScroll}
paramPrefix={paramPrefix}
controlledPage={controlledPage}
controlledPageSize={controlledPageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
)}
</div>
@@ -3,8 +3,8 @@
import { Snippet } from "@heroui/snippet";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Alert, AlertDescription } from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
interface ApiKeySuccessModalProps {
isOpen: boolean;
@@ -18,8 +18,8 @@ export const ApiKeySuccessModal = ({
apiKey,
}: ApiKeySuccessModalProps) => {
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={(open) => !open && onClose()}
title="API Key Created Successfully"
>
@@ -56,6 +56,6 @@ export const ApiKeySuccessModal = ({
Acknowledged
</Button>
</div>
</CustomAlertModal>
</Modal>
);
};
@@ -5,9 +5,9 @@ import { useForm } from "react-hook-form";
import * as z from "zod";
import { createApiKey } from "@/actions/api-keys/api-keys";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form, FormButtons } from "@/components/ui/form";
@@ -86,8 +86,8 @@ export const CreateApiKeyModal = ({
};
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Create API Key"
size="lg"
@@ -139,6 +139,6 @@ export const CreateApiKeyModal = ({
/>
</form>
</Form>
</CustomAlertModal>
</Modal>
);
};
@@ -6,9 +6,9 @@ import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateApiKey } from "@/actions/api-keys/api-keys";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form, FormButtons } from "@/components/ui/form";
import { EnrichedApiKey } from "./api-keys/types";
@@ -92,8 +92,8 @@ export const EditApiKeyNameModal = ({
};
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Edit API Key Name"
size="lg"
@@ -129,6 +129,6 @@ export const EditApiKeyNameModal = ({
/>
</form>
</Form>
</CustomAlertModal>
</Modal>
);
};
@@ -4,7 +4,7 @@ import { Chip } from "@heroui/chip";
import { useState } from "react";
import { Button, Card } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { Modal } from "@/components/shadcn/modal";
import { DateWithTime, InfoField } from "@/components/ui/entities";
import { MembershipDetailData } from "@/types/users";
@@ -25,17 +25,13 @@ export const MembershipItem = ({
return (
<>
<CustomAlertModal
isOpen={isEditOpen}
onOpenChange={setIsEditOpen}
title=""
>
<Modal open={isEditOpen} onOpenChange={setIsEditOpen} title="">
<EditTenantForm
tenantId={tenantId}
tenantName={tenantName}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
</Modal>
<Card variant="inner" className="min-w-[320px] p-2">
<div className="flex w-full items-center gap-4">
<Chip size="sm" variant="flat" color="secondary">
@@ -4,12 +4,12 @@ import { Snippet } from "@heroui/snippet";
import { Trash2Icon } from "lucide-react";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import { Modal } from "@/components/shadcn/modal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
import { FALLBACK_VALUES } from "./api-keys/constants";
@@ -49,8 +49,8 @@ export const RevokeApiKeyModal = ({
});
return (
<CustomAlertModal
isOpen={isOpen}
<Modal
open={isOpen}
onOpenChange={(open) => !open && handleClose()}
title="Revoke API Key"
size="lg"
@@ -95,6 +95,6 @@ export const RevokeApiKeyModal = ({
submitColor="danger"
submitIcon={<Trash2Icon size={24} />}
/>
</CustomAlertModal>
</Modal>
);
};
@@ -17,7 +17,7 @@ import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { Modal } from "@/components/shadcn/modal";
import { DeleteForm, EditForm } from "../forms";
@@ -41,8 +41,8 @@ export function DataTableRowActions<UserProps>({
return (
<>
<CustomAlertModal
isOpen={isEditOpen}
<Modal
open={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit user details"
>
@@ -55,15 +55,15 @@ export function DataTableRowActions<UserProps>({
roles={roles || []}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
<CustomAlertModal
isOpen={isDeleteOpen}
</Modal>
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<Dropdown
+63
View File
@@ -0,0 +1,63 @@
"use client";
import {
createContext,
ReactNode,
TransitionStartFunction,
useContext,
useTransition,
} from "react";
interface FilterTransitionContextType {
isPending: boolean;
startTransition: TransitionStartFunction;
}
const FilterTransitionContext = createContext<
FilterTransitionContextType | undefined
>(undefined);
export const useFilterTransition = () => {
const context = useContext(FilterTransitionContext);
if (!context) {
throw new Error(
"useFilterTransition must be used within a FilterTransitionProvider",
);
}
return context;
};
/**
* Optional hook that returns undefined if not within a provider.
* Useful for components that may or may not be within a provider.
*/
export const useFilterTransitionOptional = () => {
return useContext(FilterTransitionContext);
};
interface FilterTransitionProviderProps {
children: ReactNode;
}
export const FilterTransitionProvider = ({
children,
}: FilterTransitionProviderProps) => {
const [isPending, startTransition] = useTransition();
return (
<FilterTransitionContext.Provider value={{ isPending, startTransition }}>
{children}
</FilterTransitionContext.Provider>
);
};
/**
* Convenience wrapper that provides filter transition context.
* Use this in pages to enable coordinated loading states across
* all filter components and the DataTable.
*/
export const FilterTransitionWrapper = ({
children,
}: FilterTransitionProviderProps) => {
return <FilterTransitionProvider>{children}</FilterTransitionProvider>;
};
+6
View File
@@ -0,0 +1,6 @@
export {
FilterTransitionProvider,
FilterTransitionWrapper,
useFilterTransition,
useFilterTransitionOptional,
} from "./filter-transition-context";
+27 -4
View File
@@ -1,17 +1,33 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { useCallback, useTransition } from "react";
import { useFilterTransitionOptional } from "@/contexts";
/**
* Custom hook to handle URL filters and automatically reset
* pagination when filters change.
*
* Uses useTransition to prevent full page reloads when filters change,
* keeping the current UI visible while the new data loads.
*
* When used within a FilterTransitionProvider, the transition state is shared
* across all components using this hook, enabling coordinated loading indicators.
*/
export const useUrlFilters = () => {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// Use shared context if available, otherwise fall back to local transition
const sharedTransition = useFilterTransitionOptional();
const [localIsPending, localStartTransition] = useTransition();
const isPending = sharedTransition?.isPending ?? localIsPending;
const startTransition =
sharedTransition?.startTransition ?? localStartTransition;
const updateFilter = useCallback(
(key: string, value: string | string[] | null) => {
const params = new URLSearchParams(searchParams.toString());
@@ -41,9 +57,11 @@ export const useUrlFilters = () => {
params.set(filterKey, nextValue);
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[router, searchParams, pathname],
[router, searchParams, pathname, startTransition],
);
const clearFilter = useCallback(
@@ -58,9 +76,11 @@ export const useUrlFilters = () => {
params.set("page", "1");
}
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
});
},
[router, searchParams, pathname],
[router, searchParams, pathname, startTransition],
);
const clearAllFilters = useCallback(() => {
@@ -73,8 +93,10 @@ export const useUrlFilters = () => {
params.delete("page");
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
});
}, [router, searchParams, pathname, startTransition]);
const hasFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
@@ -88,5 +110,6 @@ export const useUrlFilters = () => {
clearFilter,
clearAllFilters,
hasFilters,
isPending,
};
};
+1 -2
View File
@@ -78,8 +78,7 @@ test.describe("Session Error Messages", () => {
await context.clearCookies();
// Try to navigate to a different protected route
// Use gotoFresh to ensure middleware runs without cached state
await providersPage.gotoFresh("/providers");
await providersPage.goto();
// Should be redirected to login with callbackUrl
await signInPage.verifyRedirectWithCallback("/providers");
+1
View File
@@ -10,6 +10,7 @@ export interface ResourceProps {
service: string;
tags: Record<string, string>;
type: string;
groups: string[] | null;
failed_findings_count: number;
details: string | null;
partition: string | null;