diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 4affd8594b..b66da755fc 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -13,6 +13,11 @@ All notable changes to the **Prowler UI** are documented in this file.
- Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685)
+### 🔄 Changed
+
+- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692)
+- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state
+
### 🐞 Fixed
- Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662)
diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts
index f71fe989fb..709469b416 100644
--- a/ui/actions/finding-groups/finding-groups.test.ts
+++ b/ui/actions/finding-groups/finding-groups.test.ts
@@ -70,7 +70,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
+ "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
);
});
@@ -84,7 +84,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
+ "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
);
});
});
@@ -106,7 +106,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows",
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at",
+ "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
);
});
@@ -120,7 +120,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows",
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
+ "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
);
});
});
@@ -262,7 +262,7 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
});
@@ -300,7 +300,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
});
@@ -344,7 +344,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
expect(url.searchParams.get("page[number]")).toBe("2");
expect(url.searchParams.get("page[size]")).toBe("50");
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
@@ -372,7 +372,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
expect(url.searchParams.get("page[number]")).toBe("3");
expect(url.searchParams.get("page[size]")).toBe("20");
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
expect(url.searchParams.get("filter[status]")).toBeNull();
});
@@ -443,7 +443,7 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
@@ -533,7 +533,7 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
const calledUrl = fetchMock.mock.calls[0][0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("sort")).toBe(
- "-status,-delta,-severity,-last_seen_at",
+ "-status,-severity,-delta,-last_seen_at",
);
expect(url.searchParams.get("filter[name__icontains]")).toBe(
"instance-prod",
diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts
index c409d804b7..30798ea3e6 100644
--- a/ui/actions/finding-groups/finding-groups.ts
+++ b/ui/actions/finding-groups/finding-groups.ts
@@ -83,13 +83,13 @@ function normalizeFindingGroupResourceFilters(
}
const DEFAULT_FINDING_GROUPS_SORT =
- "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at";
+ "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at";
const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED =
- "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
+ "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at";
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
- "-status,-delta,-severity,-last_seen_at";
+ "-status,-severity,-delta,-last_seen_at";
interface FetchFindingGroupsParams {
page?: number;
diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts
index 344c5607a0..5a848fff4b 100644
--- a/ui/actions/findings/findings-by-resource.ts
+++ b/ui/actions/findings/findings-by-resource.ts
@@ -262,7 +262,6 @@ export const getLatestFindingsByResourceUid = async ({
);
url.searchParams.append("filter[resource_uid]", resourceUid);
- url.searchParams.append("filter[status]", "FAIL");
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts
index afaf91a381..600346e6c6 100644
--- a/ui/actions/resources/index.ts
+++ b/ui/actions/resources/index.ts
@@ -3,6 +3,7 @@ export {
getLatestResources,
getMetadataInfo,
getResourceById,
+ getResourceDrawerData,
getResourceEvents,
getResources,
} from "./resources";
diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts
index 36f9761959..b0831bdb26 100644
--- a/ui/actions/resources/resources.ts
+++ b/ui/actions/resources/resources.ts
@@ -2,9 +2,12 @@
import { redirect } from "next/navigation";
+import { getLatestFindings } from "@/actions/findings";
+import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
+import { OrganizationResource } from "@/types/organizations";
export const getResources = async ({
page = 1,
@@ -255,3 +258,57 @@ export const getResourceById = async (
return undefined;
}
};
+
+export const getResourceDrawerData = async ({
+ resourceId,
+ resourceUid,
+ providerId,
+ providerType,
+ page = 1,
+ pageSize = 10,
+ query = "",
+}: {
+ resourceId: string;
+ resourceUid: string;
+ providerId: string;
+ providerType: string;
+ page?: number;
+ pageSize?: number;
+ query?: string;
+}) => {
+ const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
+
+ const [resourceData, findingsResponse, organizationsResponse] =
+ await Promise.all([
+ getResourceById(resourceId, { fields: ["tags"] }),
+ getLatestFindings({
+ page,
+ pageSize,
+ query,
+ sort: "severity,-inserted_at",
+ filters: {
+ "filter[resource_uid]": resourceUid,
+ "filter[status]": "FAIL",
+ },
+ }),
+ isCloudEnv && providerType === "aws"
+ ? listOrganizationsSafe()
+ : Promise.resolve({ data: [] }),
+ ]);
+
+ const providerOrg =
+ providerType === "aws"
+ ? (organizationsResponse.data.find((organization: OrganizationResource) =>
+ organization.relationships?.providers?.data?.some(
+ (provider: { id: string }) => provider.id === providerId,
+ ),
+ ) ?? null)
+ : null;
+
+ return {
+ findings: findingsResponse?.data ?? [],
+ findingsMeta: findingsResponse?.meta ?? null,
+ providerOrg,
+ resourceTags: resourceData?.data?.attributes.tags ?? {},
+ };
+};
diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts
new file mode 100644
index 0000000000..03b990c1d5
--- /dev/null
+++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts
@@ -0,0 +1,16 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("findings view overview SSR", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "findings-view.ssr.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("uses the non-legacy latest findings columns", () => {
+ expect(source).toContain("ColumnLatestFindings");
+ expect(source).not.toContain("ColumnNewFindingsToDate");
+ });
+});
diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx
index be2aec7770..9554869dce 100644
--- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx
+++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx
@@ -3,7 +3,7 @@
import { getLatestFindings } from "@/actions/findings/findings";
import { LighthouseBanner } from "@/components/lighthouse/banner";
import { LinkToFindings } from "@/components/overview";
-import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date";
+import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
@@ -73,7 +73,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx
new file mode 100644
index 0000000000..d7209c68c8
--- /dev/null
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx
@@ -0,0 +1,93 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { GraphNode } from "@/types/attack-paths";
+
+import { NodeDetailPanel } from "./node-detail-panel";
+
+vi.mock("@/components/ui/sheet/sheet", () => ({
+ Sheet: ({ children }: { children: ReactNode }) =>
{children}
,
+ SheetContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ SheetDescription: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ SheetHeader: ({ children }: { children: ReactNode }) => {children}
,
+ SheetTitle: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock("./node-overview", () => ({
+ NodeOverview: () => Node overview
,
+}));
+
+vi.mock("./node-findings", () => ({
+ NodeFindings: () => Node findings
,
+}));
+
+vi.mock("./node-resources", () => ({
+ NodeResources: () => Node resources
,
+}));
+
+const findingNode: GraphNode = {
+ id: "graph-node-id",
+ labels: ["ProwlerFinding"],
+ properties: {
+ id: "finding-123",
+ check_title: "Open S3 bucket",
+ name: "Open S3 bucket",
+ },
+};
+
+const resourceNode: GraphNode = {
+ id: "resource-node-id",
+ labels: ["S3Bucket"],
+ properties: {
+ id: "bucket-123",
+ name: "bucket-123",
+ },
+};
+
+describe("NodeDetailPanel", () => {
+ it("renders the view finding button only for finding nodes", () => {
+ const { rerender } = render();
+
+ expect(
+ screen.getByRole("button", { name: /view finding finding-123/i }),
+ ).toBeInTheDocument();
+
+ rerender();
+
+ expect(
+ screen.queryByRole("button", { name: /view finding/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls onViewFinding with the node finding id", async () => {
+ const user = userEvent.setup();
+ const onViewFinding = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /view finding finding-123/i }),
+ );
+
+ expect(onViewFinding).toHaveBeenCalledWith("finding-123");
+ });
+
+ it("disables the button and shows the spinner while loading", () => {
+ render();
+
+ const button = screen.getByRole("button", {
+ name: /view finding finding-123/i,
+ });
+
+ expect(button).toBeDisabled();
+ expect(screen.getByLabelText("Loading")).toHaveClass("size-4");
+ });
+});
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
index 4d8885e65c..97d8657bfd 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
@@ -1,6 +1,7 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
+import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Sheet,
SheetContent,
@@ -18,6 +19,8 @@ interface NodeDetailPanelProps {
node: GraphNode | null;
allNodes?: GraphNode[];
onClose?: () => void;
+ onViewFinding?: (findingId: string) => void;
+ viewFindingLoading?: boolean;
}
/**
@@ -26,9 +29,13 @@ interface NodeDetailPanelProps {
export const NodeDetailContent = ({
node,
allNodes = [],
+ onViewFinding,
+ viewFindingLoading = false,
}: {
node: GraphNode;
allNodes?: GraphNode[];
+ onViewFinding?: (findingId: string) => void;
+ viewFindingLoading?: boolean;
}) => {
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
@@ -56,7 +63,12 @@ export const NodeDetailContent = ({
Findings connected to this node
-
+
)}
@@ -88,12 +100,15 @@ export const NodeDetailPanel = ({
node,
allNodes = [],
onClose,
+ onViewFinding,
+ viewFindingLoading = false,
}: NodeDetailPanelProps) => {
const isOpen = node !== null;
const isProwlerFinding = node?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
+ const findingId = node ? String(node.properties?.id || node.id) : "";
return (
!open && onClose?.()}>
@@ -107,15 +122,19 @@ export const NodeDetailPanel = ({
{node && isProwlerFinding && (
-
{finding.properties?.description && (
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx
index e3421e5a43..3ece3184f0 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx
@@ -1,8 +1,8 @@
"use client";
-import Link from "next/link";
-
import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { Spinner } from "@/components/shadcn/spinner/spinner";
interface Finding {
id: string;
@@ -13,12 +13,18 @@ interface Finding {
interface NodeRemediationProps {
findings: Finding[];
+ onViewFinding?: (findingId: string) => void;
+ viewFindingLoading?: boolean;
}
/**
* Node remediation section showing related Prowler findings
*/
-export const NodeRemediation = ({ findings }: NodeRemediationProps) => {
+export const NodeRemediation = ({
+ findings,
+ onViewFinding,
+ viewFindingLoading = false,
+}: NodeRemediationProps) => {
const getSeverityVariant = (severity: string) => {
switch (severity) {
case "critical":
@@ -66,15 +72,20 @@ export const NodeRemediation = ({ findings }: NodeRemediationProps) => {
- onViewFinding?.(finding.id)}
+ disabled={viewFindingLoading}
aria-label={`View full finding for ${finding.title}`}
- className="text-text-info dark:text-text-info text-sm transition-all hover:opacity-80 dark:hover:opacity-80"
+ className="text-text-info dark:text-text-info h-auto p-0 text-sm transition-all hover:opacity-80 dark:hover:opacity-80"
>
- View Full Finding →
-
+ {viewFindingLoading ? (
+
+ ) : (
+ "View Full Finding →"
+ )}
+
))}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
index 4a9cfe84b7..548301d3df 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
@@ -14,6 +14,8 @@ import {
getAvailableQueries,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
+import { FindingDetailDrawer } from "@/components/findings/table";
+import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import {
Alert,
@@ -30,6 +32,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
+import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import type {
AttackPathQuery,
@@ -65,6 +68,7 @@ export default function AttackPathsPage() {
const searchParams = useSearchParams();
const scanId = searchParams.get("scanId");
const graphState = useGraphState();
+ const finding = useFindingDetails();
const { toast } = useToast();
const [scansLoading, setScansLoading] = useState(true);
@@ -311,6 +315,14 @@ export default function AttackPathsPage() {
graphState.selectNode(null);
};
+ const getFindingId = (node: GraphNode | null) =>
+ node ? String(node.properties?.id || node.id) : "";
+
+ const handleViewFinding = (findingId: string) => {
+ if (!findingId) return;
+ void finding.navigateToFinding(findingId);
+ };
+
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
try {
if (svgElement) {
@@ -684,15 +696,20 @@ export default function AttackPathsPage() {
{graphState.selectedNode.labels.some((label) =>
label.toLowerCase().includes("finding"),
) && (
-
-
- View Finding →
-
+
+ handleViewFinding(getFindingId(graphState.selectedNode))
+ }
+ disabled={finding.findingDetailLoading}
+ aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
+ >
+ {finding.findingDetailLoading ? (
+
+ ) : (
+ "View Finding"
+ )}
)}
)}
+
+ {finding.findingDetails && (
+ {
+ if (!open) finding.resetFindingDetails();
+ }}
+ />
+ )}
>
)}
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index 2211800451..948d264602 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -37,9 +37,6 @@ export default async function Findings({
// Check if the searchParams contain any date or scan filter
const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams);
- // TODO: Re-implement deep link support (/findings?id=) using the grouped view's resource detail drawer
- // once the legacy FindingDetailsSheet is fully deprecated (still used by /resources and overview dashboard).
-
const [providersData, scansData] = await Promise.all([
getProviders({ pageSize: 50 }),
getScans({ pageSize: 50 }),
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts
new file mode 100644
index 0000000000..7f724e20b7
--- /dev/null
+++ b/ui/components/compliance/compliance-accordion/client-accordion-content.test.ts
@@ -0,0 +1,16 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("client accordion content", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "client-accordion-content.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("uses the shared standalone finding columns instead of the legacy findings columns", () => {
+ expect(source).toContain("getStandaloneFindingColumns");
+ expect(source).not.toContain("getColumnFindings");
+ });
+});
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
index 965df073a1..f582f2b3c2 100644
--- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
+++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
- getColumnFindings,
+ getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Accordion } from "@/components/ui/accordion/Accordion";
@@ -33,6 +33,7 @@ export const ClientAccordionContent = ({
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const complianceId = searchParams.get("complianceId");
+ const openFindingId = searchParams.get("id");
const defaultSort = "severity,status,-inserted_at";
const sort = searchParams.get("sort") || defaultSort;
const loadedPageRef = useRef(null);
@@ -159,12 +160,7 @@ export const ClientAccordionContent = ({
Findings
- col.id !== "select" &&
- !("accessorKey" in col && col.accessorKey === "updated_at"),
- )}
+ columns={getStandaloneFindingColumns({ openFindingId })}
data={expandedFindings || []}
metadata={findings?.meta}
disableScroll={true}
diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx
similarity index 55%
rename from ui/components/findings/table/column-findings.tsx
rename to ui/components/findings/table/column-standalone-findings.tsx
index f6300e2b73..9854f05d9d 100644
--- a/ui/components/findings/table/column-findings.tsx
+++ b/ui/components/findings/table/column-standalone-findings.tsx
@@ -1,14 +1,8 @@
-// TODO: Legacy columns — used by overview dashboard (column-new-findings-to-date.tsx).
-// Migrate that consumer to grouped view columns, then delete this file.
"use client";
-import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
+import { ColumnDef } from "@tanstack/react-table";
import { Database } from "lucide-react";
-import { useSearchParams } from "next/navigation";
-import { FindingDetail } from "@/components/findings/table";
-import { DataTableRowActions } from "@/components/findings/table";
-import { Checkbox } from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities";
import {
@@ -18,11 +12,15 @@ import {
} from "@/components/ui/table";
import { FindingProps, ProviderType } from "@/types";
-// TODO: PROWLER-379 - Enable ImpactedResourcesCell when backend supports grouped findings
-// import { ImpactedResourcesCell } from "./impacted-resources-cell";
+import { FindingDetailDrawer } from "./finding-detail-drawer";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
import { ProviderIconCell } from "./provider-icon-cell";
+interface GetStandaloneFindingColumnsOptions {
+ includeUpdatedAt?: boolean;
+ openFindingId?: string | null;
+}
+
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
@@ -45,49 +43,38 @@ const getProviderData = (
return row.original.relationships?.provider?.attributes?.[field] || "-";
};
-// Component for finding title that opens the detail drawer
-const FindingTitleCell = ({ row }: { row: { original: FindingProps } }) => {
- const searchParams = useSearchParams();
- const findingId = searchParams.get("id");
- const isOpen = findingId === row.original.id;
- const { checktitle } = row.original.attributes.check_metadata;
-
+function FindingTitleCell({
+ finding,
+ defaultOpen = false,
+}: {
+ finding: FindingProps;
+ defaultOpen?: boolean;
+}) {
return (
-
- {checktitle}
+ {finding.attributes.check_metadata.checktitle}
}
/>
);
-};
+}
-// Function to generate columns with access to selection state
-export function getColumnFindings(
- rowSelection: RowSelectionState,
- selectableRowCount: number,
-): ColumnDef[] {
- // Calculate selection state from rowSelection for header checkbox
- const selectedCount = Object.values(rowSelection).filter(Boolean).length;
- const isAllSelected =
- selectedCount > 0 && selectedCount === selectableRowCount;
- const isSomeSelected =
- selectedCount > 0 && selectedCount < selectableRowCount;
-
- return [
- // Notification column - shows new/changed/muted indicators
+export function getStandaloneFindingColumns({
+ includeUpdatedAt = false,
+ openFindingId = null,
+}: GetStandaloneFindingColumnsOptions = {}): ColumnDef[] {
+ const columns: ColumnDef[] = [
{
id: "notification",
header: () => null,
cell: ({ row }) => {
const finding = row.original;
- const isMuted = finding.attributes.muted;
- const mutedReason = finding.attributes.muted_reason;
const delta = finding.attributes.delta as
| (typeof DeltaValues)[keyof typeof DeltaValues]
| undefined;
@@ -95,8 +82,8 @@ export function getColumnFindings(
return (
);
@@ -104,51 +91,6 @@ export function getColumnFindings(
enableSorting: false,
enableHiding: false,
},
- // Select column
- {
- id: "select",
- header: ({ table }) => {
- const headerChecked = isAllSelected
- ? true
- : isSomeSelected
- ? "indeterminate"
- : false;
-
- return (
-
-
- table.toggleAllPageRowsSelected(checked === true)
- }
- aria-label="Select all"
- disabled={selectableRowCount === 0}
- />
-
- );
- },
- cell: ({ row }) => {
- const finding = row.original;
- const isMuted = finding.attributes.muted;
- const isSelected = !!rowSelection[row.id];
-
- return (
-
-
- row.toggleSelected(checked === true)
- }
- aria-label="Select row"
- />
-
- );
- },
- enableSorting: false,
- enableHiding: false,
- },
- // Status column
{
accessorKey: "status",
header: ({ column }) => (
@@ -162,7 +104,6 @@ export function getColumnFindings(
return ;
},
},
- // Finding column - clickable to open detail sheet
{
accessorKey: "check",
header: ({ column }) => (
@@ -172,9 +113,13 @@ export function getColumnFindings(
param="check_id"
/>
),
- cell: ({ row }) => ,
+ cell: ({ row }) => (
+
+ ),
},
- // Resource name column
{
accessorKey: "resourceName",
header: ({ column }) => (
@@ -197,7 +142,6 @@ export function getColumnFindings(
},
enableSorting: false,
},
- // Severity column
{
accessorKey: "severity",
header: ({ column }) => (
@@ -214,7 +158,6 @@ export function getColumnFindings(
return ;
},
},
- // Provider column
{
accessorKey: "provider",
header: ({ column }) => (
@@ -227,7 +170,6 @@ export function getColumnFindings(
},
enableSorting: false,
},
- // Service column
{
accessorKey: "service",
header: ({ column }) => (
@@ -243,7 +185,6 @@ export function getColumnFindings(
},
enableSorting: false,
},
- // Region column
{
accessorKey: "region",
header: ({ column }) => (
@@ -260,19 +201,10 @@ export function getColumnFindings(
},
enableSorting: false,
},
- // TODO: PROWLER-379 - Enable Impacted Resources column when backend supports grouped findings
- // {
- // accessorKey: "impactedResources",
- // header: ({ column }) => (
- //
- // ),
- // cell: () => {
- // return ;
- // },
- // enableSorting: false,
- // },
- // Time column
- {
+ ];
+
+ if (includeUpdatedAt) {
+ columns.push({
accessorKey: "updated_at",
header: ({ column }) => (
;
},
- },
- // Actions column - dropdown with Mute/Jira options
- {
- id: "actions",
- header: () => ,
- cell: ({ row }) => ,
- enableSorting: false,
- },
- ];
+ });
+ }
+
+ return columns;
}
diff --git a/ui/components/findings/table/data-table-row-details.tsx b/ui/components/findings/table/data-table-row-details.tsx
deleted file mode 100644
index 54f0f96eeb..0000000000
--- a/ui/components/findings/table/data-table-row-details.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-"use client";
-
-import { FindingProps } from "@/types/components";
-
-import { FindingDetail } from "./finding-detail";
-
-export const DataTableRowDetails = ({
- findingDetails,
-}: {
- entityId: string;
- findingDetails: FindingProps;
-}) => {
- return ;
-};
diff --git a/ui/components/findings/table/finding-detail-drawer.test.ts b/ui/components/findings/table/finding-detail-drawer.test.ts
new file mode 100644
index 0000000000..4054c67f1d
--- /dev/null
+++ b/ui/components/findings/table/finding-detail-drawer.test.ts
@@ -0,0 +1,22 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("finding detail drawer", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "finding-detail-drawer.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("uses the shared resource detail drawer hook with single-resource mode", () => {
+ expect(source).toContain("useResourceDetailDrawer");
+ expect(source).toContain("totalResourceCount: 1");
+ expect(source).toContain("initialIndex: defaultOpen || inline ? 0 : null");
+ });
+
+ it("renders the new resource detail drawer content instead of the legacy finding detail component", () => {
+ expect(source).toContain("ResourceDetailDrawerContent");
+ expect(source).not.toContain('from "./finding-detail"');
+ });
+});
diff --git a/ui/components/findings/table/finding-detail-drawer.tsx b/ui/components/findings/table/finding-detail-drawer.tsx
new file mode 100644
index 0000000000..5281686d20
--- /dev/null
+++ b/ui/components/findings/table/finding-detail-drawer.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { findingToFindingResourceRow } from "@/lib/finding-detail";
+import type { FindingProps } from "@/types/components";
+
+import {
+ ResourceDetailDrawer,
+ useResourceDetailDrawer,
+} from "./resource-detail-drawer";
+import { ResourceDetailDrawerContent } from "./resource-detail-drawer/resource-detail-drawer-content";
+
+interface FindingDetailDrawerProps {
+ finding: FindingProps;
+ trigger?: ReactNode;
+ defaultOpen?: boolean;
+ inline?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onMuteComplete?: () => void;
+}
+
+export function FindingDetailDrawer({
+ finding,
+ trigger,
+ defaultOpen = false,
+ inline = false,
+ onOpenChange,
+ onMuteComplete,
+}: FindingDetailDrawerProps) {
+ const drawer = useResourceDetailDrawer({
+ resources: [findingToFindingResourceRow(finding)],
+ checkId: finding.attributes.check_id,
+ totalResourceCount: 1,
+ initialIndex: defaultOpen || inline ? 0 : null,
+ });
+
+ const handleOpen = () => {
+ drawer.openDrawer(0);
+ onOpenChange?.(true);
+ };
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ drawer.openDrawer(0);
+ } else {
+ drawer.closeDrawer();
+ }
+
+ onOpenChange?.(open);
+ };
+
+ const handleMuteComplete = () => {
+ drawer.refetchCurrent();
+ onMuteComplete?.();
+ };
+
+ if (inline) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {trigger ? (
+
+ {trigger}
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/ui/components/findings/table/finding-detail.test.tsx b/ui/components/findings/table/finding-detail.test.tsx
deleted file mode 100644
index ec2e84e2fb..0000000000
--- a/ui/components/findings/table/finding-detail.test.tsx
+++ /dev/null
@@ -1,299 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { FindingProps } from "@/types";
-
-import { FindingDetail } from "./finding-detail";
-
-// Mock next/navigation
-const mockRefresh = vi.fn();
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ refresh: mockRefresh }),
- usePathname: () => "/findings",
- useSearchParams: () => new URLSearchParams(),
-}));
-
-// Mock @/components/shadcn to avoid next-auth import chain
-vi.mock("@/components/shadcn", () => {
- const Slot = ({ children }: { children: React.ReactNode }) => <>{children}>;
- return {
- Button: ({
- children,
- ...props
- }: React.ButtonHTMLAttributes & {
- variant?: string;
- size?: string;
- }) => {children},
- Drawer: ({ children }: { children: React.ReactNode }) => <>{children}>,
- DrawerClose: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- DrawerContent: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- DrawerDescription: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- DrawerHeader: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- DrawerTitle: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- DrawerTrigger: Slot,
- InfoField: ({
- children,
- label,
- }: {
- children: React.ReactNode;
- label: string;
- variant?: string;
- }) => (
-
- {label}
- {children}
-
- ),
- Tabs: ({ children }: { children: React.ReactNode }) => <>{children}>,
- TabsContent: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- TabsList: ({ children }: { children: React.ReactNode }) => <>{children}>,
- TabsTrigger: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
- TooltipContent: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
- TooltipTrigger: Slot,
- };
-});
-
-vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
- CodeSnippet: ({ value }: { value: string }) => {value},
-}));
-
-vi.mock("@/components/ui/custom/custom-link", () => ({
- CustomLink: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
-}));
-
-vi.mock("@/components/ui/entities", () => ({
- EntityInfo: () => ,
-}));
-
-vi.mock("@/components/ui/entities/date-with-time", () => ({
- DateWithTime: ({ dateTime }: { dateTime: string }) => {dateTime},
-}));
-
-vi.mock("@/components/ui/table/severity-badge", () => ({
- SeverityBadge: ({ severity }: { severity: string }) => (
- {severity}
- ),
-}));
-
-vi.mock("@/components/ui/table/status-finding-badge", () => ({
- FindingStatus: {},
- StatusFindingBadge: ({ status }: { status: string }) => {status},
-}));
-
-vi.mock("@/lib/iac-utils", () => ({
- buildGitFileUrl: () => null,
- extractLineRangeFromUid: () => null,
-}));
-
-vi.mock("@/lib/utils", () => ({
- cn: (...args: string[]) => args.filter(Boolean).join(" "),
-}));
-
-// Mock child components that are not under test
-vi.mock("../mute-findings-modal", () => ({
- MuteFindingsModal: ({
- isOpen,
- findingIds,
- }: {
- isOpen: boolean;
- findingIds: string[];
- }) =>
- isOpen ? (
- Muting {findingIds.length} finding(s)
- ) : null,
-}));
-
-vi.mock("../muted", () => ({
- Muted: ({ isMuted }: { isMuted: boolean }) =>
- isMuted ? Muted : null,
-}));
-
-vi.mock("./delta-indicator", () => ({
- DeltaIndicator: () => null,
-}));
-
-vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
- EventsTimeline: () => ,
-}));
-
-vi.mock("react-markdown", () => ({
- default: ({ children }: { children: string }) => {children},
-}));
-
-const baseFinding: FindingProps = {
- type: "findings",
- id: "finding-123",
- attributes: {
- uid: "uid-123",
- delta: null,
- status: "FAIL",
- status_extended: "S3 bucket is publicly accessible",
- severity: "high",
- check_id: "s3_bucket_public_access",
- muted: false,
- check_metadata: {
- risk: "Public access risk",
- notes: "",
- checkid: "s3_bucket_public_access",
- provider: "aws",
- severity: "high",
- checktype: [],
- dependson: [],
- relatedto: [],
- categories: ["security"],
- checktitle: "S3 Bucket Public Access Check",
- compliance: null,
- relatedurl: "",
- description: "Checks if S3 buckets are publicly accessible",
- remediation: {
- code: { cli: "", other: "", nativeiac: "", terraform: "" },
- recommendation: { url: "", text: "" },
- },
- servicename: "s3",
- checkaliases: [],
- resourcetype: "AwsS3Bucket",
- subservicename: "",
- resourceidtemplate: "",
- },
- raw_result: null,
- inserted_at: "2024-01-01T00:00:00Z",
- updated_at: "2024-01-02T00:00:00Z",
- first_seen_at: "2024-01-01T00:00:00Z",
- },
- relationships: {
- resources: { data: [{ type: "resources", id: "res-1" }] },
- scan: {
- data: { type: "scans", id: "scan-1" },
- attributes: {
- name: "Daily Scan",
- trigger: "scheduled",
- state: "completed",
- unique_resource_count: 50,
- progress: 100,
- scanner_args: { checks_to_execute: [] },
- duration: 120,
- started_at: "2024-01-01T00:00:00Z",
- inserted_at: "2024-01-01T00:00:00Z",
- completed_at: "2024-01-01T00:02:00Z",
- scheduled_at: null,
- next_scan_at: "2024-01-02T00:00:00Z",
- },
- },
- resource: {
- data: [{ type: "resources", id: "res-1" }],
- id: "res-1",
- attributes: {
- uid: "arn:aws:s3:::my-bucket",
- name: "my-bucket",
- region: "us-east-1",
- service: "s3",
- tags: {},
- type: "AwsS3Bucket",
- inserted_at: "2024-01-01T00:00:00Z",
- updated_at: "2024-01-01T00:00:00Z",
- details: null,
- partition: "aws",
- },
- relationships: {
- provider: { data: { type: "providers", id: "prov-1" } },
- findings: {
- meta: { count: 1 },
- data: [{ type: "findings", id: "finding-123" }],
- },
- },
- links: { self: "/resources/res-1" },
- },
- provider: {
- data: { type: "providers", id: "prov-1" },
- attributes: {
- provider: "aws",
- uid: "123456789012",
- alias: "my-account",
- connection: {
- connected: true,
- last_checked_at: "2024-01-01T00:00:00Z",
- },
- inserted_at: "2024-01-01T00:00:00Z",
- updated_at: "2024-01-01T00:00:00Z",
- },
- relationships: {
- secret: { data: { type: "provider-secrets", id: "secret-1" } },
- },
- links: { self: "/providers/prov-1" },
- },
- },
- links: { self: "/findings/finding-123" },
-};
-
-describe("FindingDetail", () => {
- it("shows the Mute button for non-muted findings", () => {
- render();
-
- expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument();
- });
-
- it("hides the Mute button for muted findings", () => {
- const mutedFinding: FindingProps = {
- ...baseFinding,
- attributes: { ...baseFinding.attributes, muted: true },
- };
-
- render();
-
- expect(screen.queryByRole("button", { name: /mute/i })).toBeNull();
- });
-
- it("opens the mute modal when clicking the Mute button", async () => {
- const user = userEvent.setup();
-
- render();
-
- expect(screen.queryByTestId("mute-modal")).toBeNull();
-
- await user.click(screen.getByRole("button", { name: /mute/i }));
-
- expect(screen.getByTestId("mute-modal")).toBeInTheDocument();
- });
-
- it("does not render the mute modal for muted findings", () => {
- const mutedFinding: FindingProps = {
- ...baseFinding,
- attributes: { ...baseFinding.attributes, muted: true },
- };
-
- render();
-
- expect(screen.queryByTestId("mute-modal")).toBeNull();
- });
-
- it("shows the muted badge for muted findings", () => {
- const mutedFinding: FindingProps = {
- ...baseFinding,
- attributes: { ...baseFinding.attributes, muted: true },
- };
-
- render();
-
- expect(screen.getByTestId("muted-badge")).toBeInTheDocument();
- });
-});
diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx
deleted file mode 100644
index 1b95a6653b..0000000000
--- a/ui/components/findings/table/finding-detail.tsx
+++ /dev/null
@@ -1,502 +0,0 @@
-// TODO: Legacy component — used by /resources page and overview dashboard.
-// Migrate those consumers to the new resource-detail-drawer, then delete this file.
-"use client";
-
-import { ExternalLink, Link, VolumeX, X } from "lucide-react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { type ReactNode, useState } from "react";
-
-import {
- Button,
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
- InfoField,
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/shadcn";
-import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
-import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
-import { CustomLink } from "@/components/ui/custom/custom-link";
-import { EntityInfo } from "@/components/ui/entities";
-import { DateWithTime } from "@/components/ui/entities/date-with-time";
-import { SeverityBadge } from "@/components/ui/table/severity-badge";
-import {
- FindingStatus,
- StatusFindingBadge,
-} from "@/components/ui/table/status-finding-badge";
-import { formatDuration } from "@/lib/date-utils";
-import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
-import { cn } from "@/lib/utils";
-import { FindingProps, ProviderType } from "@/types";
-
-import { MarkdownContainer } from "../markdown-container";
-import { MuteFindingsModal } from "../mute-findings-modal";
-import { Muted } from "../muted";
-import { DeltaIndicator } from "./delta-indicator";
-
-const renderValue = (value: string | null | undefined) => {
- return value && value.trim() !== "" ? value : "-";
-};
-
-interface FindingDetailProps {
- findingDetails: FindingProps;
- trigger?: ReactNode;
- open?: boolean;
- defaultOpen?: boolean;
- onOpenChange?: (open: boolean) => void;
-}
-
-export const FindingDetail = ({
- findingDetails,
- trigger,
- open,
- defaultOpen = false,
- onOpenChange,
-}: FindingDetailProps) => {
- const finding = findingDetails;
- const attributes = finding.attributes;
- const resource = finding.relationships?.resource?.attributes;
- const scan = finding.relationships?.scan?.attributes;
- const providerDetails = finding.relationships?.provider?.attributes;
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
-
- const copyFindingUrl = () => {
- const params = new URLSearchParams(searchParams.toString());
- params.set("id", findingDetails.id);
- const url = `${window.location.origin}${pathname}?${params.toString()}`;
- navigator.clipboard.writeText(url);
- };
-
- // Build Git URL for IaC findings
- const gitUrl =
- providerDetails?.provider === "iac" && resource
- ? buildGitFileUrl(
- providerDetails.uid,
- resource.name,
- extractLineRangeFromUid(attributes.uid) || "",
- resource.region,
- )
- : null;
-
- const handleMuteComplete = () => {
- setIsMuteModalOpen(false);
- onOpenChange?.(false);
- router.refresh();
- };
-
- const muteModal = !attributes.muted && (
-
- );
-
- const content = (
-
- {/* Header */}
-
- {/* Row 1: Status badges */}
-
-
-
- {attributes.delta && (
-
-
-
- {attributes.delta}
-
-
- )}
-
-
-
- {/* Row 2: Title with copy link */}
-
- {renderValue(attributes.check_metadata.checktitle)}
-
-
-
-
-
-
- Copy finding link to clipboard
-
-
-
- {/* Row 3: First Seen */}
-
- Time:
-
-
-
-
- {/* Tabs */}
-
-
-
- General
- Resources
- Scans
- Events
-
-
- {!attributes.muted && (
- setIsMuteModalOpen(true)}
- >
-
- Mute
-
- )}
-
-
- {/* General Tab */}
-
-
- Here is an overview of this finding:
-
-
- {providerDetails && (
-
- )}
-
- {attributes.check_metadata.servicename}
-
- {resource?.region ?? "-"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {attributes.status === "FAIL" && (
-
-
-
- {attributes.check_metadata.risk}
-
-
-
- )}
-
-
-
- {attributes.check_metadata.description}
-
-
-
-
- {renderValue(attributes.status_extended)}
-
-
- {attributes.check_metadata.remediation && (
-
-
- Remediation Details
-
-
- {/* Recommendation section */}
- {attributes.check_metadata.remediation.recommendation.text && (
-
-
-
- {
- attributes.check_metadata.remediation.recommendation
- .text
- }
-
-
- {attributes.check_metadata.remediation.recommendation
- .url && (
-
- Learn more
-
- )}
-
-
- )}
-
- {/* CLI Command section */}
- {attributes.check_metadata.remediation.code.cli && (
-
-
-
- {attributes.check_metadata.remediation.code.cli}
-
-
-
- )}
-
- {/* Remediation Steps section */}
- {attributes.check_metadata.remediation.code.other && (
-
-
- {attributes.check_metadata.remediation.code.other}
-
-
- )}
-
- {/* Additional URLs section */}
- {attributes.check_metadata.additionalurls &&
- attributes.check_metadata.additionalurls.length > 0 && (
-
-
- {attributes.check_metadata.additionalurls.map(
- (link, idx) => (
- -
-
- {link}
-
-
- ),
- )}
-
-
- )}
-
- )}
-
-
- {attributes.check_metadata.categories?.join(", ") || "none"}
-
-
-
- {/* Resources Tab */}
-
- {resource ? (
- <>
- {providerDetails?.provider === "iac" && gitUrl && (
-
- )}
-
-
-
- {renderValue(resource.name)}
-
-
- {renderValue(resource.type)}
-
-
-
-
-
- {renderValue(resource.service)}
-
-
- {renderValue(resource.region)}
-
-
-
-
-
- {renderValue(resource.partition)}
-
-
- {renderValue(resource.details)}
-
-
-
-
-
-
-
- {resource.tags && Object.entries(resource.tags).length > 0 && (
-
-
- Tags
-
-
- {Object.entries(resource.tags).map(([key, value]) => (
-
- {renderValue(value)}
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
- Resource information is not available.
-
- )}
-
-
- {/* Scans Tab */}
-
- {scan ? (
- <>
-
- {scan.name || "N/A"}
-
- {scan.unique_resource_count}
-
- {scan.progress}%
-
-
-
- {scan.trigger}
- {scan.state}
-
- {formatDuration(scan.duration)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {scan.scheduled_at && (
-
-
-
- )}
-
- >
- ) : (
-
- Scan information is not available.
-
- )}
-
-
- {/* Events Tab */}
-
-
-
-
-
- );
-
- // If no trigger, render content directly (inline mode)
- if (!trigger) {
- return (
- <>
- {muteModal}
- {content}
- >
- );
- }
-
- // With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues
- return (
- <>
- {muteModal}
-
- {trigger}
-
-
- Finding Details
- View the finding details
-
-
-
- Close
-
- {content}
-
-
- >
- );
-};
diff --git a/ui/components/findings/table/findings-group-drill-down.test.ts b/ui/components/findings/table/findings-group-drill-down.test.ts
new file mode 100644
index 0000000000..816e894e0a
--- /dev/null
+++ b/ui/components/findings/table/findings-group-drill-down.test.ts
@@ -0,0 +1,16 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("findings group drill down", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "findings-group-drill-down.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("uses the shared finding-group resource state hook", () => {
+ expect(source).toContain("useFindingGroupResourceState");
+ expect(source).not.toContain("useInfiniteResources");
+ });
+});
diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx
index 3d35b20115..445adb6ec7 100644
--- a/ui/components/findings/table/findings-group-drill-down.tsx
+++ b/ui/components/findings/table/findings-group-drill-down.tsx
@@ -3,15 +3,12 @@
import {
flexRender,
getCoreRowModel,
- Row,
- RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
-import { useState } from "react";
-import { Spinner } from "@/components/shadcn/spinner/spinner";
+import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Table,
TableBody,
@@ -21,24 +18,20 @@ import {
TableRow,
} from "@/components/ui/table";
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
-import { useInfiniteResources } from "@/hooks/use-infinite-resources";
+import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import {
getFilteredFindingGroupDelta,
isFindingGroupMuted,
} from "@/lib/findings-groups";
-import { FindingGroupRow, FindingResourceRow } from "@/types";
+import { FindingGroupRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
-import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
-import {
- ResourceDetailDrawer,
- useResourceDetailDrawer,
-} from "./resource-detail-drawer";
+import { ResourceDetailDrawer } from "./resource-detail-drawer";
interface FindingsGroupDrillDownProps {
group: FindingGroupRow;
@@ -50,9 +43,6 @@ export function FindingsGroupDrillDown({
onCollapse,
}: FindingsGroupDrillDownProps) {
const searchParams = useSearchParams();
- const [rowSelection, setRowSelection] = useState({});
- const [resources, setResources] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
// Keep drill-down endpoint selection aligned with the grouped findings page.
const currentParams = Object.fromEntries(searchParams.entries());
@@ -66,78 +56,27 @@ export function FindingsGroupDrillDown({
}
});
- const handleSetResources = (
- newResources: FindingResourceRow[],
- _hasMore: boolean,
- ) => {
- setResources(newResources);
- setIsLoading(false);
- };
-
- const handleAppendResources = (
- newResources: FindingResourceRow[],
- _hasMore: boolean,
- ) => {
- setResources((prev) => [...prev, ...newResources]);
- setIsLoading(false);
- };
-
- const handleSetLoading = (loading: boolean) => {
- setIsLoading(loading);
- };
-
- const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
- checkId: group.checkId,
- hasDateOrScanFilter: hasHistoricalFilterActive,
- filters,
- onSetResources: handleSetResources,
- onAppendResources: handleAppendResources,
- onSetLoading: handleSetLoading,
- });
-
- // Resource detail drawer
- const drawer = useResourceDetailDrawer({
+ const {
+ rowSelection,
resources,
- checkId: group.checkId,
- totalResourceCount: totalCount ?? group.resourcesTotal,
- onRequestMoreResources: loadMore,
+ isLoading,
+ sentinelRef,
+ drawer,
+ handleDrawerMuteComplete,
+ selectedFindingIds,
+ selectableRowCount,
+ getRowCanSelect,
+ clearSelection,
+ isSelected,
+ handleMuteComplete,
+ handleRowSelectionChange,
+ resolveSelectedFindingIds,
+ } = useFindingGroupResourceState({
+ group,
+ filters,
+ hasHistoricalData: hasHistoricalFilterActive,
});
- const handleDrawerMuteComplete = () => {
- drawer.refetchCurrent();
- refresh();
- };
-
- // Selection logic — tracks by findingId (resource_id) for checkbox consistency
- const selectedFindingIds = Object.keys(rowSelection)
- .filter((key) => rowSelection[key])
- .map((idx) => resources[parseInt(idx)]?.findingId)
- .filter((id): id is string => id !== null && id !== undefined && id !== "");
-
- /** findingId values are already real finding UUIDs — no resolution needed. */
- const resolveResourceIds = async (ids: string[]) => {
- return ids.filter(Boolean);
- };
-
- const selectableRowCount = resources.filter(canMuteFindingResource).length;
-
- const getRowCanSelect = (row: Row): boolean => {
- return canMuteFindingResource(row.original);
- };
-
- const clearSelection = () => {
- setRowSelection({});
- };
-
- const isSelected = (id: string) => {
- return selectedFindingIds.includes(id);
- };
-
- const handleMuteComplete = () => {
- clearSelection();
- refresh();
- };
-
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
@@ -148,7 +87,7 @@ export function FindingsGroupDrillDown({
columns,
enableRowSelection: getRowCanSelect,
getCoreRowModel: getCoreRowModel(),
- onRowSelectionChange: setRowSelection,
+ onRowSelectionChange: handleRowSelectionChange,
manualPagination: true,
state: {
rowSelection,
@@ -175,7 +114,7 @@ export function FindingsGroupDrillDown({
selectedFindings: [],
clearSelection,
isSelected,
- resolveMuteIds: resolveResourceIds,
+ resolveMuteIds: resolveSelectedFindingIds,
onMuteComplete: handleMuteComplete,
}}
>
@@ -280,14 +219,7 @@ export function FindingsGroupDrillDown({
{/* Loading indicator */}
- {isLoading && (
-
-
-
- Loading resources...
-
-
- )}
+ {isLoading && }
{/* Sentinel for infinite scroll */}
@@ -299,7 +231,7 @@ export function FindingsGroupDrillDown({
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onBeforeOpen={async () => {
- return resolveResourceIds(selectedFindingIds);
+ return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation
diff --git a/ui/components/findings/table/index.ts b/ui/components/findings/table/index.ts
index b3b063a6ed..f8b6b4fdbe 100644
--- a/ui/components/findings/table/index.ts
+++ b/ui/components/findings/table/index.ts
@@ -1,16 +1,11 @@
export * from "./column-finding-groups";
export * from "./column-finding-resources";
-export * from "./column-findings";
+export * from "./column-standalone-findings";
export * from "./data-table-row-actions";
-export * from "./data-table-row-details";
-export * from "./finding-detail";
+export * from "./finding-detail-drawer";
export * from "./findings-group-drill-down";
export * from "./findings-group-table";
export * from "./findings-selection-context";
-// TODO: Remove legacy exports once /resources and overview dashboard migrate to grouped view components
-// export * from "./column-findings";
-// export * from "./data-table-row-details";
-// export * from "./finding-detail";
export * from "./impacted-resources-cell";
export * from "./notification-indicator";
export * from "./provider-icon-cell";
diff --git a/ui/components/findings/table/inline-resource-container.test.ts b/ui/components/findings/table/inline-resource-container.test.ts
new file mode 100644
index 0000000000..3acafa8ee5
--- /dev/null
+++ b/ui/components/findings/table/inline-resource-container.test.ts
@@ -0,0 +1,16 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("inline resource container", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "inline-resource-container.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("uses the shared finding-group resource state hook", () => {
+ expect(source).toContain("useFindingGroupResourceState");
+ expect(source).not.toContain("useInfiniteResources");
+ });
+});
diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx
index c2f40e189f..47e6d0eb06 100644
--- a/ui/components/findings/table/inline-resource-container.tsx
+++ b/ui/components/findings/table/inline-resource-container.tsx
@@ -3,32 +3,26 @@
import {
flexRender,
getCoreRowModel,
- Row,
- RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
-import { useImperativeHandle, useRef, useState } from "react";
+import { useImperativeHandle, useRef } from "react";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
-import { Spinner } from "@/components/shadcn/spinner/spinner";
+import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/ui/table";
-import { useInfiniteResources } from "@/hooks/use-infinite-resources";
+import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { useScrollHint } from "@/hooks/use-scroll-hint";
-import { FindingGroupRow, FindingResourceRow } from "@/types";
+import { FindingGroupRow } from "@/types";
import { getColumnFindingResources } from "./column-finding-resources";
-import { canMuteFindingResource } from "./finding-resource-selection";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
getFilteredFindingGroupResourceCount,
getFindingGroupSkeletonCount,
} from "./inline-resource-container.utils";
-import {
- ResourceDetailDrawer,
- useResourceDetailDrawer,
-} from "./resource-detail-drawer";
+import { ResourceDetailDrawer } from "./resource-detail-drawer";
export interface InlineResourceContainerHandle {
/** Soft-refresh resources (re-fetch page 1 without skeletons). */
@@ -140,22 +134,6 @@ export function InlineResourceContainer({
ref,
}: InlineResourceContainerProps) {
const scrollContainerRef = useRef(null);
- const [rowSelection, setRowSelection] = useState({});
- const [resources, setResources] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- // Scroll hint: shows "scroll for more" when content overflows
- const {
- containerRef: scrollHintContainerRef,
- sentinelRef: scrollHintSentinelRef,
- showScrollHint,
- } = useScrollHint({ refreshToken: resources.length });
-
- // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
- const combinedScrollRef = (node: HTMLDivElement | null) => {
- scrollContainerRef.current = node;
- scrollHintContainerRef(node);
- };
-
const filters: Record = { ...resolvedFilters };
if (resourceSearch) {
filters["filter[name__icontains]"] = resourceSearch;
@@ -171,99 +149,45 @@ export function InlineResourceContainer({
filters,
);
- const handleSetResources = (
- newResources: FindingResourceRow[],
- _hasMore: boolean,
- ) => {
- setResources(newResources);
- setIsLoading(false);
- };
-
- const handleAppendResources = (
- newResources: FindingResourceRow[],
- _hasMore: boolean,
- ) => {
- setResources((prev) => [...prev, ...newResources]);
- setIsLoading(false);
- };
-
- const handleSetLoading = (loading: boolean) => {
- setIsLoading(loading);
- };
-
- const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({
- checkId: group.checkId,
- hasDateOrScanFilter: hasHistoricalData,
+ const {
+ rowSelection,
+ resources,
+ isLoading,
+ sentinelRef,
+ refresh,
+ drawer,
+ handleDrawerMuteComplete,
+ selectedFindingIds,
+ selectableRowCount,
+ getRowCanSelect,
+ clearSelection,
+ isSelected,
+ handleMuteComplete,
+ handleRowSelectionChange,
+ resolveSelectedFindingIds,
+ } = useFindingGroupResourceState({
+ group,
filters,
- onSetResources: handleSetResources,
- onAppendResources: handleAppendResources,
- onSetLoading: handleSetLoading,
+ hasHistoricalData,
+ onResourceSelectionChange,
scrollContainerRef,
});
- // Resource detail drawer
- const drawer = useResourceDetailDrawer({
- resources,
- checkId: group.checkId,
- totalResourceCount: totalCount ?? group.resourcesTotal,
- onRequestMoreResources: loadMore,
- });
+ // Scroll hint: shows "scroll for more" when content overflows
+ const {
+ containerRef: scrollHintContainerRef,
+ sentinelRef: scrollHintSentinelRef,
+ showScrollHint,
+ } = useScrollHint({ refreshToken: resources.length });
- const handleDrawerMuteComplete = () => {
- drawer.refetchCurrent();
- refresh();
- };
-
- // Selection logic
- const selectedFindingIds = Object.keys(rowSelection)
- .filter((key) => rowSelection[key])
- .map((idx) => resources[parseInt(idx)]?.findingId)
- .filter(Boolean);
-
- const resolveResourceIds = async (ids: string[]) => {
- // findingId values are already real finding UUIDs (from the group
- // resources endpoint), so no second resolution round-trip is needed.
- return ids.filter(Boolean);
- };
-
- const selectableRowCount = resources.filter(canMuteFindingResource).length;
-
- const getRowCanSelect = (row: Row): boolean => {
- return canMuteFindingResource(row.original);
- };
-
- const clearSelection = () => {
- setRowSelection({});
- onResourceSelectionChange([]);
+ // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
+ const combinedScrollRef = (node: HTMLDivElement | null) => {
+ scrollContainerRef.current = node;
+ scrollHintContainerRef(node);
};
useImperativeHandle(ref, () => ({ refresh, clearSelection }));
- const isSelected = (id: string) => {
- return selectedFindingIds.includes(id);
- };
-
- const handleMuteComplete = () => {
- clearSelection();
- refresh();
- };
-
- const handleRowSelectionChange = (
- updater:
- | RowSelectionState
- | ((prev: RowSelectionState) => RowSelectionState),
- ) => {
- const newSelection =
- typeof updater === "function" ? updater(rowSelection) : updater;
- setRowSelection(newSelection);
-
- const newFindingIds = Object.keys(newSelection)
- .filter((key) => newSelection[key])
- .map((idx) => resources[parseInt(idx)]?.findingId)
- .filter(Boolean);
- onResourceSelectionChange(newFindingIds);
- };
-
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
@@ -290,7 +214,7 @@ export function InlineResourceContainer({
selectedFindings: [],
clearSelection,
isSelected,
- resolveMuteIds: resolveResourceIds,
+ resolveMuteIds: resolveSelectedFindingIds,
onMuteComplete: handleMuteComplete,
}}
>
@@ -363,14 +287,9 @@ export function InlineResourceContainer({
- {/* Spinner for infinite scroll (subsequent pages only) */}
+ {/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
-
-
-
- Loading resources...
-
-
+
)}
{/* Sentinel for scroll hint detection */}
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
index e075c89b2b..d488462b14 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
@@ -37,7 +37,7 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
-import { Spinner } from "@/components/shadcn/spinner/spinner";
+import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Tooltip,
TooltipContent,
@@ -873,9 +873,7 @@ export function ResourceDetailDrawerContent({
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
>
{!f || isNavigating ? (
-
-
-
+
) : (
<>
diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts
index 3e4d1ef540..797676562f 100644
--- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts
+++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts
@@ -191,7 +191,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
id: "other-1",
checkId: "check-other-1",
checkTitle: "Other 1",
- status: "PASS",
+ status: "FAIL",
severity: "critical",
}),
makeDrawerFinding({
@@ -221,6 +221,55 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
]);
});
+ it("should exclude non-FAIL findings from otherFindings", async () => {
+ const resources = [makeResource()];
+
+ getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
+ adaptFindingsByResourceResponseMock.mockReturnValue([
+ makeDrawerFinding({
+ id: "current",
+ checkId: "s3_check",
+ status: "MANUAL",
+ severity: "informational",
+ }),
+ makeDrawerFinding({
+ id: "other-pass",
+ checkId: "check-pass",
+ status: "PASS",
+ severity: "low",
+ }),
+ makeDrawerFinding({
+ id: "other-manual",
+ checkId: "check-manual",
+ status: "MANUAL",
+ severity: "low",
+ }),
+ makeDrawerFinding({
+ id: "other-fail",
+ checkId: "check-fail",
+ status: "FAIL",
+ severity: "high",
+ }),
+ ]);
+
+ const { result } = renderHook(() =>
+ useResourceDetailDrawer({
+ resources,
+ checkId: "s3_check",
+ }),
+ );
+
+ await act(async () => {
+ result.current.openDrawer(0);
+ await Promise.resolve();
+ });
+
+ expect(result.current.currentFinding?.id).toBe("current");
+ expect(result.current.otherFindings.map((f) => f.id)).toEqual([
+ "other-fail",
+ ]);
+ });
+
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
vi.useFakeTimers();
diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts
index f092c606dc..687d9ac739 100644
--- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts
+++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts
@@ -46,6 +46,7 @@ interface UseResourceDetailDrawerOptions {
checkId: string;
totalResourceCount?: number;
onRequestMoreResources?: () => void;
+ initialIndex?: number | null;
}
interface UseResourceDetailDrawerReturn {
@@ -77,10 +78,11 @@ export function useResourceDetailDrawer({
checkId,
totalResourceCount,
onRequestMoreResources,
+ initialIndex = null,
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
- const [isOpen, setIsOpen] = useState(false);
+ const [isOpen, setIsOpen] = useState(initialIndex !== null);
const [isLoading, setIsLoading] = useState(false);
- const [currentIndex, setCurrentIndex] = useState(0);
+ const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0);
const [findings, setFindings] = useState
([]);
const [isNavigating, setIsNavigating] = useState(false);
@@ -190,6 +192,22 @@ export function useResourceDetailDrawer({
}
};
+ useEffect(() => {
+ if (initialIndex === null) {
+ return;
+ }
+
+ const resource = resources[initialIndex];
+ if (!resource) {
+ return;
+ }
+
+ fetchFindings(resource.resourceUid);
+ // Only initialize once on mount for deep-link/inline entry points.
+ // User-driven navigations use openDrawer/navigateTo afterwards.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const openDrawer = (index: number) => {
const resource = resources[index];
if (!resource) return;
@@ -251,10 +269,13 @@ export function useResourceDetailDrawer({
const currentFinding =
findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null;
- // All other findings for this resource
- const otherFindings = currentFinding
- ? findings.filter((f) => f.id !== currentFinding.id)
- : findings;
+ // "Other Findings For This Resource" intentionally shows only FAIL entries,
+ // while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…).
+ const otherFindings = (
+ currentFinding
+ ? findings.filter((f) => f.id !== currentFinding.id)
+ : findings
+ ).filter((f) => f.status === "FAIL");
return {
isOpen,
diff --git a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx
new file mode 100644
index 0000000000..6310e2dc40
--- /dev/null
+++ b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+
+import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
+import { FindingProps } from "@/types";
+
+export const ColumnLatestFindings: ColumnDef[] =
+ getStandaloneFindingColumns({
+ includeUpdatedAt: true,
+ });
diff --git a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx
deleted file mode 100644
index 94eeca391d..0000000000
--- a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-"use client";
-
-import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
-
-import { getColumnFindings } from "@/components/findings/table/column-findings";
-import { FindingProps } from "@/types";
-
-const baseColumns: ColumnDef[] = getColumnFindings(
- {} as RowSelectionState,
- 0,
-).filter((column) => column.id !== "select" && column.id !== "actions");
-
-export const ColumnNewFindingsToDate: ColumnDef[] = baseColumns;
diff --git a/ui/components/overview/new-findings-table/table/index.ts b/ui/components/overview/new-findings-table/table/index.ts
index 5f8e56989e..7065a82bf8 100644
--- a/ui/components/overview/new-findings-table/table/index.ts
+++ b/ui/components/overview/new-findings-table/table/index.ts
@@ -1,2 +1,2 @@
-export * from "./column-new-findings-to-date";
+export * from "./column-latest-findings";
export * from "./skeleton-table-new-findings";
diff --git a/ui/components/resources/resource-details-sheet.test.ts b/ui/components/resources/resource-details-sheet.test.ts
new file mode 100644
index 0000000000..9f630ee352
--- /dev/null
+++ b/ui/components/resources/resource-details-sheet.test.ts
@@ -0,0 +1,16 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("resource details sheet", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "resource-details-sheet.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("forces a remount when switching resources so local drawer state resets without effects", () => {
+ expect(source).toContain("key={resource.id}");
+ expect(source).toContain("resourceDetails={resource}");
+ });
+});
diff --git a/ui/components/resources/resource-details-sheet.tsx b/ui/components/resources/resource-details-sheet.tsx
index 1581f36520..1ae3ec0e74 100644
--- a/ui/components/resources/resource-details-sheet.tsx
+++ b/ui/components/resources/resource-details-sheet.tsx
@@ -36,7 +36,9 @@ export const ResourceDetailsSheet = ({
Close
- {open && }
+ {open && (
+
+ )}
);
diff --git a/ui/components/resources/skeleton/skeleton-finding-details.tsx b/ui/components/resources/skeleton/skeleton-finding-details.tsx
deleted file mode 100644
index 062a5fb7c7..0000000000
--- a/ui/components/resources/skeleton/skeleton-finding-details.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from "react";
-
-export const SkeletonFindingDetails = () => {
- return (
-
- {/* Header */}
-
-
- {/* Metadata Section */}
-
- {Array.from({ length: 6 }).map((_, index) => (
-
- ))}
-
-
- {/* InfoField Blocks */}
- {Array.from({ length: 3 }).map((_, index) => (
-
- ))}
-
- {/* Risk and Description Sections */}
-
-
-
-
-
-
-
-
- {/* Additional Resources */}
-
-
- {/* Categories */}
-
-
- {/* Provider Info Section */}
-
-
- );
-};
diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx
deleted file mode 100644
index dde0fee56d..0000000000
--- a/ui/components/resources/skeleton/skeleton-finding-summary.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from "react";
-
-export const SkeletonFindingSummary = () => {
- return (
-
- );
-};
diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts
index a03bacf648..65e520c5ce 100644
--- a/ui/components/resources/table/index.ts
+++ b/ui/components/resources/table/index.ts
@@ -1,5 +1,4 @@
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";
diff --git a/ui/components/resources/table/resource-detail-content.test.ts b/ui/components/resources/table/resource-detail-content.test.ts
new file mode 100644
index 0000000000..a60c3bfb9f
--- /dev/null
+++ b/ui/components/resources/table/resource-detail-content.test.ts
@@ -0,0 +1,29 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("resource detail content", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "resource-detail-content.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("renders the new finding detail drawer flow instead of the legacy finding detail component", () => {
+ expect(source).toContain("FindingDetailDrawer");
+ expect(source).not.toContain("FindingDetail findingDetails");
+ });
+
+ it("loads the drawer bootstrap data through a single shared resource action", () => {
+ expect(source).toContain("useResourceDrawerBootstrap");
+ expect(source).not.toContain("getResourceDrawerData");
+ expect(source).not.toContain("listOrganizationsSafe");
+ expect(source).not.toContain("getResourceById");
+ expect(source).not.toContain("getLatestFindings");
+ });
+
+ it("does not import useEffect directly and relies on hooks/keyed remounts instead", () => {
+ expect(source).not.toContain("useEffect");
+ expect(source).not.toContain("useEffect(");
+ });
+});
diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx
index ac1800acc6..6492f7f685 100644
--- a/ui/components/resources/table/resource-detail-content.tsx
+++ b/ui/components/resources/table/resource-detail-content.tsx
@@ -8,16 +8,12 @@ import {
CornerDownRight,
ExternalLink,
Link,
- Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
-import { getFindingById, getLatestFindings } from "@/actions/findings";
-import { listOrganizationsSafe } from "@/actions/organizations/organizations";
-import { getResourceById } from "@/actions/resources";
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
-import { FindingDetail } from "@/components/findings/table/finding-detail";
+import { FindingDetailDrawer } from "@/components/findings/table";
import {
Card,
Tabs,
@@ -32,56 +28,23 @@ import {
InfoField,
InfoTooltip,
} from "@/components/shadcn/info-field/info-field";
+import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable } from "@/components/ui/table";
-import { createDict } from "@/lib";
import { getGroupLabel } from "@/lib/categories";
import { buildGitFileUrl } from "@/lib/iac-utils";
import { getRegionFlag } from "@/lib/region-flags";
-import {
- FindingProps,
- MetaDataProps,
- ProviderType,
- ResourceProps,
-} from "@/types";
-import { OrganizationResource } from "@/types/organizations";
+import { ProviderType, ResourceProps } from "@/types";
import {
getResourceFindingsColumns,
ResourceFinding,
} from "./resource-findings-columns";
-
-function useProviderOrganization(
- providerId: string,
- providerType: string,
-): OrganizationResource | null {
- const [org, setOrg] = useState(null);
- const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
-
- useEffect(() => {
- if (!isCloudEnv || providerType !== "aws") {
- setOrg(null);
- return;
- }
-
- const loadOrg = async () => {
- const response = await listOrganizationsSafe();
- const found = response.data.find((o: OrganizationResource) =>
- o.relationships?.providers?.data?.some(
- (p: { id: string }) => p.id === providerId,
- ),
- );
- setOrg(found ?? null);
- };
-
- loadOrg();
- }, [isCloudEnv, providerType, providerId]);
-
- return org;
-}
+import { useFindingDetails } from "./use-finding-details";
+import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
@@ -146,27 +109,16 @@ interface ResourceDetailContentProps {
export const ResourceDetailContent = ({
resourceDetails,
}: ResourceDetailContentProps) => {
- const [findingsData, setFindingsData] = useState([]);
- const [findingsMetadata, setFindingsMetadata] =
- useState(null);
- const [resourceTags, setResourceTags] = useState>({});
- const [findingsLoading, setFindingsLoading] = useState(true);
- const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
const [selectedFindingId, setSelectedFindingId] = useState(
null,
);
- const [findingDetails, setFindingDetails] = useState(
- null,
- );
- const [findingDetailLoading, setFindingDetailLoading] = useState(false);
const [rowSelection, setRowSelection] = useState({});
const [activeTab, setActiveTab] = useState("findings");
const [metadataCopied, setMetadataCopied] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
- const findingFetchRef = useRef(null);
const router = useRouter();
const resource = resourceDetails;
@@ -174,22 +126,29 @@ export const ResourceDetailContent = ({
const attributes = resource.attributes;
const providerData = resource.relationships.provider.data.attributes;
const providerId = resource.relationships.provider.data.id;
- const providerOrg = useProviderOrganization(
+ const {
+ findingsData,
+ findingsMetadata,
+ findingsLoading,
+ hasInitiallyLoaded,
+ providerOrg,
+ resourceTags,
+ } = useResourceDrawerBootstrap({
+ resourceId,
+ resourceUid: attributes.uid,
providerId,
- providerData.provider,
- );
-
- // Reset to overview tab when switching resources
- useEffect(() => {
- setActiveTab("findings");
- }, [resourceId]);
-
- // Cleanup abort controller on unmount
- useEffect(() => {
- return () => {
- findingFetchRef.current?.abort();
- };
- }, []);
+ providerType: providerData.provider,
+ currentPage,
+ pageSize,
+ searchQuery,
+ findingsReloadNonce,
+ });
+ const {
+ findingDetails,
+ findingDetailLoading,
+ navigateToFinding: loadFindingDetails,
+ resetFindingDetails,
+ } = useFindingDetails();
const copyResourceUrl = () => {
const url = `${window.location.origin}/resources?resourceId=${resourceId}`;
@@ -202,119 +161,14 @@ export const ResourceDetailContent = ({
setTimeout(() => setMetadataCopied(false), 2000);
};
- // Load resource tags on mount
- 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) {
- loadResourceTags();
- }
- }, [resourceId]);
-
- // Load findings with server-side pagination and search
- useEffect(() => {
- const loadFindings = async () => {
- setFindingsLoading(true);
-
- try {
- const findingsResponse = await getLatestFindings({
- page: currentPage,
- pageSize,
- query: searchQuery,
- sort: "severity,-inserted_at",
- filters: {
- "filter[resource_uid]": attributes.uid,
- "filter[status]": "FAIL",
- },
- });
-
- if (findingsResponse?.data) {
- setFindingsMetadata(findingsResponse.meta || null);
- setFindingsData(findingsResponse.data as ResourceFinding[]);
- } else {
- setFindingsData([]);
- setFindingsMetadata(null);
- }
- } catch (err) {
- console.error("Error loading findings:", err);
- setFindingsData([]);
- setFindingsMetadata(null);
- } finally {
- setFindingsLoading(false);
- setHasInitiallyLoaded(true);
- }
- };
-
- if (attributes.uid) {
- loadFindings();
- }
- }, [attributes.uid, currentPage, pageSize, searchQuery, findingsReloadNonce]);
-
const navigateToFinding = async (findingId: string) => {
- if (findingFetchRef.current) {
- findingFetchRef.current.abort();
- }
- findingFetchRef.current = new AbortController();
-
setSelectedFindingId(findingId);
- setFindingDetailLoading(true);
-
- try {
- const findingData = await getFindingById(
- findingId,
- "resources,scan.provider",
- );
-
- if (findingFetchRef.current?.signal.aborted) {
- return;
- }
-
- if (findingData?.data) {
- const resourceDict = createDict("resources", findingData);
- const scanDict = createDict("scans", findingData);
- const providerDict = createDict("providers", findingData);
-
- const finding = findingData.data;
- const scan = scanDict[finding.relationships?.scan?.data?.id];
- const foundResource =
- resourceDict[finding.relationships?.resources?.data?.[0]?.id];
- const provider = providerDict[scan?.relationships?.provider?.data?.id];
-
- const expandedFinding = {
- ...finding,
- relationships: { scan, resource: foundResource, provider },
- };
-
- setFindingDetails(expandedFinding);
- }
- } catch (error) {
- if (error instanceof Error && error.name === "AbortError") {
- return;
- }
- console.error("Error fetching finding:", error);
- } finally {
- if (!findingFetchRef.current?.signal.aborted) {
- setFindingDetailLoading(false);
- }
- }
+ await loadFindingDetails(findingId);
};
const handleBackToResource = () => {
setSelectedFindingId(null);
- setFindingDetails(null);
- setFindingDetailLoading(false);
+ resetFindingDetails();
};
const handleMuteComplete = (_findingIds?: string[]) => {
@@ -332,11 +186,6 @@ export const ResourceDetailContent = ({
(f) => !f.attributes.muted,
).length;
- // Reset selection when page changes
- useEffect(() => {
- setRowSelection({});
- }, [currentPage, pageSize]);
-
const totalFindings = findingsMetadata?.pagination?.count || 0;
const getRowCanSelect = (row: Row): boolean =>
@@ -396,14 +245,16 @@ export const ResourceDetailContent = ({
/>
{findingDetailLoading ? (
-
-
-
- Loading finding details...
-
-
+
) : (
- findingDetails &&
+ findingDetails && (
+
+ )
)}
);
@@ -543,12 +394,7 @@ export const ResourceDetailContent = ({
{findingsLoading && !hasInitiallyLoaded ? (
-
-
-
- Loading findings...
-
-
+
) : (
<>
{
+ setRowSelection({});
setSearchQuery(value);
setCurrentPage(1);
}}
controlledPage={currentPage}
controlledPageSize={pageSize}
- onPageChange={setCurrentPage}
- onPageSizeChange={setPageSize}
+ onPageChange={(page) => {
+ setRowSelection({});
+ setCurrentPage(page);
+ }}
+ onPageSizeChange={(size) => {
+ setRowSelection({});
+ setCurrentPage(1);
+ setPageSize(size);
+ }}
isLoading={findingsLoading}
/>
{selectedFindingIds.length > 0 && (
@@ -655,10 +509,12 @@ export const ResourceDetailContent = ({
-
+ {activeTab === "events" && (
+
+ )}
diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx
deleted file mode 100644
index 88cc57095c..0000000000
--- a/ui/components/resources/table/resource-detail.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-"use client";
-
-import { X } from "lucide-react";
-import type { ReactNode } from "react";
-import { useState } from "react";
-
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/shadcn";
-import { ResourceProps } from "@/types";
-
-import { ResourceDetailContent } from "./resource-detail-content";
-
-interface ResourceDetailProps {
- resourceDetails: ResourceProps;
- trigger?: ReactNode;
- open?: boolean;
- defaultOpen?: boolean;
- onOpenChange?: (open: boolean) => void;
-}
-
-/**
- * Lightweight wrapper component for resource details.
- *
- * When used with a trigger (table rows), this component only renders the Drawer shell
- * and trigger. The heavy ResourceDetailContent is only mounted when the drawer is open,
- * preventing unnecessary state initialization and data fetching for closed drawers.
- *
- * When used without a trigger (inline mode from ResourceDetailsSheet), it renders
- * the content directly since it's already visible.
- */
-export const ResourceDetail = ({
- resourceDetails,
- trigger,
- open: controlledOpen,
- defaultOpen = false,
- onOpenChange,
-}: ResourceDetailProps) => {
- // Track internal open state for uncontrolled drawer (when using trigger)
- const [internalOpen, setInternalOpen] = useState(defaultOpen);
-
- // Determine actual open state
- const isOpen = controlledOpen ?? internalOpen;
-
- // Handle open state changes
- const handleOpenChange = (newOpen: boolean) => {
- setInternalOpen(newOpen);
- onOpenChange?.(newOpen);
- };
-
- // If no trigger, render content directly (inline mode for ResourceDetailsSheet)
- if (!trigger) {
- return ;
- }
-
- // With trigger, wrap in Drawer - content only mounts when open (lazy loading)
- return (
-
- {trigger}
-
-
- Resource Details
- View the resource details
-
-
-
- Close
-
- {/* Content only renders when drawer is open - this is the key optimization */}
- {isOpen && }
-
-
- );
-};
diff --git a/ui/components/resources/table/use-finding-details.ts b/ui/components/resources/table/use-finding-details.ts
new file mode 100644
index 0000000000..b95770a6a6
--- /dev/null
+++ b/ui/components/resources/table/use-finding-details.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+import { getFindingById } from "@/actions/findings";
+import { expandFindingWithRelationships } from "@/lib/finding-detail";
+import { FindingProps } from "@/types";
+
+interface UseFindingDetailsReturn {
+ findingDetails: FindingProps | null;
+ findingDetailLoading: boolean;
+ navigateToFinding: (findingId: string) => Promise;
+ resetFindingDetails: () => void;
+}
+
+export function useFindingDetails(): UseFindingDetailsReturn {
+ const [findingDetails, setFindingDetails] = useState(
+ null,
+ );
+ const [findingDetailLoading, setFindingDetailLoading] = useState(false);
+ const findingFetchRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ findingFetchRef.current?.abort();
+ };
+ }, []);
+
+ const navigateToFinding = async (findingId: string) => {
+ if (findingFetchRef.current) {
+ findingFetchRef.current.abort();
+ }
+ findingFetchRef.current = new AbortController();
+ setFindingDetailLoading(true);
+
+ try {
+ const findingData = await getFindingById(
+ findingId,
+ "resources,scan.provider",
+ );
+
+ if (findingFetchRef.current?.signal.aborted) {
+ return;
+ }
+
+ if (findingData?.data) {
+ setFindingDetails(expandFindingWithRelationships(findingData));
+ }
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return;
+ }
+ console.error("Error fetching finding:", error);
+ } finally {
+ if (!findingFetchRef.current?.signal.aborted) {
+ setFindingDetailLoading(false);
+ }
+ }
+ };
+
+ const resetFindingDetails = () => {
+ setFindingDetails(null);
+ setFindingDetailLoading(false);
+ };
+
+ return {
+ findingDetails,
+ findingDetailLoading,
+ navigateToFinding,
+ resetFindingDetails,
+ };
+}
diff --git a/ui/components/resources/table/use-resource-drawer-bootstrap.ts b/ui/components/resources/table/use-resource-drawer-bootstrap.ts
new file mode 100644
index 0000000000..7508a70626
--- /dev/null
+++ b/ui/components/resources/table/use-resource-drawer-bootstrap.ts
@@ -0,0 +1,115 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { getResourceDrawerData } from "@/actions/resources";
+import { MetaDataProps } from "@/types";
+import { OrganizationResource } from "@/types/organizations";
+
+import { ResourceFinding } from "./resource-findings-columns";
+
+interface UseResourceDrawerBootstrapOptions {
+ resourceId: string;
+ resourceUid: string;
+ providerId: string;
+ providerType: string;
+ currentPage: number;
+ pageSize: number;
+ searchQuery: string;
+ findingsReloadNonce: number;
+}
+
+interface UseResourceDrawerBootstrapReturn {
+ findingsData: ResourceFinding[];
+ findingsMetadata: MetaDataProps | null;
+ findingsLoading: boolean;
+ hasInitiallyLoaded: boolean;
+ providerOrg: OrganizationResource | null;
+ resourceTags: Record;
+}
+
+export function useResourceDrawerBootstrap({
+ resourceId,
+ resourceUid,
+ providerId,
+ providerType,
+ currentPage,
+ pageSize,
+ searchQuery,
+ findingsReloadNonce,
+}: UseResourceDrawerBootstrapOptions): UseResourceDrawerBootstrapReturn {
+ const [findingsData, setFindingsData] = useState([]);
+ const [findingsMetadata, setFindingsMetadata] =
+ useState(null);
+ const [resourceTags, setResourceTags] = useState>({});
+ const [findingsLoading, setFindingsLoading] = useState(true);
+ const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
+ const [providerOrg, setProviderOrg] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadResourceDrawerData = async () => {
+ setFindingsLoading(true);
+
+ try {
+ const drawerData = await getResourceDrawerData({
+ resourceId,
+ resourceUid,
+ providerId,
+ providerType,
+ page: currentPage,
+ pageSize,
+ query: searchQuery,
+ });
+
+ if (cancelled) return;
+
+ setResourceTags(drawerData.resourceTags);
+ setProviderOrg(drawerData.providerOrg);
+ setFindingsMetadata(drawerData.findingsMeta as MetaDataProps | null);
+ setFindingsData(drawerData.findings as ResourceFinding[]);
+ } catch (error) {
+ if (cancelled) return;
+ console.error("Error loading resource drawer data:", error);
+ setResourceTags({});
+ setProviderOrg(null);
+ setFindingsData([]);
+ setFindingsMetadata(null);
+ } finally {
+ if (!cancelled) {
+ setFindingsLoading(false);
+ setHasInitiallyLoaded(true);
+ }
+ }
+ };
+
+ if (resourceUid) {
+ loadResourceDrawerData();
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ currentPage,
+ findingsReloadNonce,
+ pageSize,
+ providerId,
+ providerType,
+ resourceId,
+ resourceUid,
+ searchQuery,
+ ]);
+
+ return {
+ findingsData,
+ findingsMetadata,
+ findingsLoading,
+ hasInitiallyLoaded,
+ providerOrg,
+ resourceTags,
+ };
+}
diff --git a/ui/components/shadcn/spinner/loading-state.test.tsx b/ui/components/shadcn/spinner/loading-state.test.tsx
new file mode 100644
index 0000000000..c2cddc59e7
--- /dev/null
+++ b/ui/components/shadcn/spinner/loading-state.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { LoadingState } from "./loading-state";
+
+describe("LoadingState", () => {
+ it("renders a spinner with the default size", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toHaveAttribute("aria-label", "Loading");
+ expect(
+ svg?.className.baseVal ?? svg?.getAttribute("class") ?? "",
+ ).toContain("size-6");
+ });
+
+ it("does not render a label when none is provided", () => {
+ render();
+ expect(screen.queryByText(/loading/i, { selector: "span" })).toBeNull();
+ });
+
+ it("renders the label when provided", () => {
+ render();
+ expect(screen.getByText("Loading findings...")).toBeInTheDocument();
+ });
+
+ it("forwards spinnerClassName to the Spinner so callers can override the size", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg?.getAttribute("class") ?? "").toContain("size-5");
+ });
+
+ it("forwards className to the wrapper element", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("custom-wrapper");
+ });
+});
diff --git a/ui/components/shadcn/spinner/loading-state.tsx b/ui/components/shadcn/spinner/loading-state.tsx
new file mode 100644
index 0000000000..77355c840f
--- /dev/null
+++ b/ui/components/shadcn/spinner/loading-state.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+import { Spinner } from "./spinner";
+
+interface LoadingStateProps {
+ label?: string;
+ className?: string;
+ spinnerClassName?: string;
+}
+
+export function LoadingState({
+ label,
+ className,
+ spinnerClassName,
+}: LoadingStateProps) {
+ return (
+
+
+ {label && (
+ {label}
+ )}
+
+ );
+}
diff --git a/ui/components/shared/events-timeline/events-timeline.test.ts b/ui/components/shared/events-timeline/events-timeline.test.ts
new file mode 100644
index 0000000000..41354c4747
--- /dev/null
+++ b/ui/components/shared/events-timeline/events-timeline.test.ts
@@ -0,0 +1,17 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("events timeline", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "events-timeline.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("delegates resource event loading to a dedicated hook instead of using useEffect in the component", () => {
+ expect(source).toContain("useResourceEventsTimeline");
+ expect(source).not.toContain("getResourceEvents");
+ expect(source).not.toContain("useEffect(");
+ });
+});
diff --git a/ui/components/shared/events-timeline/events-timeline.tsx b/ui/components/shared/events-timeline/events-timeline.tsx
index c4f248ed51..e386e5528d 100644
--- a/ui/components/shared/events-timeline/events-timeline.tsx
+++ b/ui/components/shared/events-timeline/events-timeline.tsx
@@ -8,9 +8,8 @@ import {
Server,
Shield,
} from "lucide-react";
-import { useEffect, useState, useTransition } from "react";
+import { useState } from "react";
-import { getResourceEvents } from "@/actions/resources";
import {
Alert,
AlertDescription,
@@ -25,6 +24,8 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { cn } from "@/lib/utils";
import { ResourceEventProps } from "@/types";
+import { useResourceEventsTimeline } from "./use-resource-events-timeline";
+
interface EventsTimelineProps {
resourceId?: string;
isAwsProvider: boolean;
@@ -34,59 +35,17 @@ export const EventsTimeline = ({
resourceId,
isAwsProvider,
}: EventsTimelineProps) => {
- const [events, setEvents] = useState([]);
- const [error, setError] = useState(null);
- const [errorStatus, setErrorStatus] = useState(null);
const [expandedRows, setExpandedRows] = useState>(new Set());
const [includeReadEvents, setIncludeReadEvents] = useState(false);
- const [hasFetched, setHasFetched] = useState(false);
const [retryCount, setRetryCount] = useState(0);
- const [isPending, startTransition] = useTransition();
-
- useEffect(() => {
- if (!isAwsProvider || !resourceId) return;
-
- let cancelled = false;
-
- setError(null);
- setErrorStatus(null);
- setHasFetched(false);
-
- startTransition(async () => {
- try {
- const response = await getResourceEvents(resourceId, {
- includeReadEvents,
- });
-
- if (cancelled) return;
-
- if (!response) {
- setError("Failed to fetch events. Please try again.");
- return;
- }
-
- if (response.error) {
- setError(response.error);
- setErrorStatus(response.status || null);
- return;
- }
-
- setEvents(response.data || []);
- setExpandedRows(new Set());
- } catch (err) {
- if (cancelled) return;
- console.error("Error fetching events:", err);
- setError("An unexpected error occurred.");
- } finally {
- if (!cancelled) setHasFetched(true);
- }
+ const { events, error, errorStatus, hasFetched, isPending } =
+ useResourceEventsTimeline({
+ resourceId,
+ isAwsProvider,
+ includeReadEvents,
+ retryCount,
});
- return () => {
- cancelled = true;
- };
- }, [resourceId, includeReadEvents, isAwsProvider, retryCount]);
-
const toggleRow = (eventId: string) => {
setExpandedRows((prev) => {
const next = new Set(prev);
diff --git a/ui/components/shared/events-timeline/use-resource-events-timeline.ts b/ui/components/shared/events-timeline/use-resource-events-timeline.ts
new file mode 100644
index 0000000000..9c0717be27
--- /dev/null
+++ b/ui/components/shared/events-timeline/use-resource-events-timeline.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+
+import { getResourceEvents } from "@/actions/resources";
+import { ResourceEventProps } from "@/types";
+
+interface UseResourceEventsTimelineOptions {
+ resourceId?: string;
+ isAwsProvider: boolean;
+ includeReadEvents: boolean;
+ retryCount: number;
+}
+
+interface UseResourceEventsTimelineReturn {
+ events: ResourceEventProps[];
+ error: string | null;
+ errorStatus: number | null;
+ hasFetched: boolean;
+ isPending: boolean;
+}
+
+export function useResourceEventsTimeline({
+ resourceId,
+ isAwsProvider,
+ includeReadEvents,
+ retryCount,
+}: UseResourceEventsTimelineOptions): UseResourceEventsTimelineReturn {
+ const [events, setEvents] = useState([]);
+ const [error, setError] = useState(null);
+ const [errorStatus, setErrorStatus] = useState(null);
+ const [hasFetched, setHasFetched] = useState(false);
+ const [isPending, startTransition] = useTransition();
+
+ useEffect(() => {
+ if (!isAwsProvider || !resourceId) return;
+
+ let cancelled = false;
+
+ setError(null);
+ setErrorStatus(null);
+ setHasFetched(false);
+
+ startTransition(async () => {
+ try {
+ const response = await getResourceEvents(resourceId, {
+ includeReadEvents,
+ });
+
+ if (cancelled) return;
+
+ if (!response) {
+ setError("Failed to fetch events. Please try again.");
+ return;
+ }
+
+ if (response.error) {
+ setError(response.error);
+ setErrorStatus(response.status || null);
+ return;
+ }
+
+ setEvents(response.data || []);
+ } catch (err) {
+ if (cancelled) return;
+ console.error("Error fetching events:", err);
+ setError("An unexpected error occurred.");
+ } finally {
+ if (!cancelled) setHasFetched(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [resourceId, includeReadEvents, isAwsProvider, retryCount]);
+
+ return {
+ events,
+ error,
+ errorStatus,
+ hasFetched,
+ isPending,
+ };
+}
diff --git a/ui/components/ui/custom/custom-link.test.ts b/ui/components/ui/custom/custom-link.test.ts
new file mode 100644
index 0000000000..1f5a47a054
--- /dev/null
+++ b/ui/components/ui/custom/custom-link.test.ts
@@ -0,0 +1,17 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+describe("custom link", () => {
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
+ const filePath = path.join(currentDir, "custom-link.tsx");
+ const source = readFileSync(filePath, "utf8");
+
+ it("renders external or placeholder hrefs as plain anchors instead of next/link", () => {
+ expect(source).toContain("isExternalHref");
+ expect(source).toContain("hasDynamicHrefPlaceholder");
+ expect(source).toContain(" {
+interface CustomLinkProps extends AnchorHTMLAttributes {
href: string;
target?: "_self" | "_blank" | string;
ariaLabel?: string;
className?: string;
- children: React.ReactNode;
+ children: ReactNode;
scroll?: boolean;
size?: string;
}
-export const CustomLink = React.forwardRef(
+function isExternalHref(href: string) {
+ return /^https?:\/\//.test(href) || href.startsWith("mailto:");
+}
+
+function hasDynamicHrefPlaceholder(href: string) {
+ return href.includes("[") && href.includes("]");
+}
+
+export const CustomLink = forwardRef(
(
{
href,
@@ -28,6 +35,29 @@ export const CustomLink = React.forwardRef(
},
ref,
) => {
+ const linkClassName = cn(
+ `text-${size} text-button-tertiary p-0`,
+ className,
+ );
+ const shouldUseAnchor =
+ isExternalHref(href) || hasDynamicHrefPlaceholder(href);
+
+ if (shouldUseAnchor) {
+ return (
+
+ {children}
+
+ );
+ }
+
return (
(
aria-label={ariaLabel}
target={target}
rel={target === "_blank" ? "noopener noreferrer" : undefined}
- className={cn(`text-${size} text-button-tertiary p-0`, className)}
+ className={linkClassName}
{...props}
>
{children}
diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts
new file mode 100644
index 0000000000..6374a5319b
--- /dev/null
+++ b/ui/hooks/use-finding-group-resource-state.ts
@@ -0,0 +1,159 @@
+"use client";
+
+import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
+import { useState } from "react";
+
+import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection";
+import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer";
+import { useFindingGroupResources } from "@/hooks/use-finding-group-resources";
+import { FindingGroupRow, FindingResourceRow } from "@/types";
+
+interface UseFindingGroupResourceStateOptions {
+ group: FindingGroupRow;
+ filters: Record;
+ hasHistoricalData: boolean;
+ onResourceSelectionChange?: (findingIds: string[]) => void;
+ scrollContainerRef?: React.RefObject;
+}
+
+interface UseFindingGroupResourceStateReturn {
+ rowSelection: RowSelectionState;
+ resources: FindingResourceRow[];
+ isLoading: boolean;
+ sentinelRef: (node: HTMLDivElement | null) => void;
+ refresh: () => void;
+ loadMore: () => void;
+ totalCount: number | null;
+ drawer: ReturnType;
+ handleDrawerMuteComplete: () => void;
+ selectedFindingIds: string[];
+ selectableRowCount: number;
+ getRowCanSelect: (row: Row) => boolean;
+ clearSelection: () => void;
+ isSelected: (id: string) => boolean;
+ handleMuteComplete: () => void;
+ handleRowSelectionChange: OnChangeFn;
+ resolveSelectedFindingIds: (ids: string[]) => Promise;
+}
+
+export function useFindingGroupResourceState({
+ group,
+ filters,
+ hasHistoricalData,
+ onResourceSelectionChange,
+ scrollContainerRef,
+}: UseFindingGroupResourceStateOptions): UseFindingGroupResourceStateReturn {
+ const [rowSelection, setRowSelection] = useState({});
+ const [resources, setResources] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const handleSetResources = (
+ newResources: FindingResourceRow[],
+ _hasMore: boolean,
+ ) => {
+ setResources(newResources);
+ setIsLoading(false);
+ };
+
+ const handleAppendResources = (
+ newResources: FindingResourceRow[],
+ _hasMore: boolean,
+ ) => {
+ setResources((prev) => [...prev, ...newResources]);
+ setIsLoading(false);
+ };
+
+ const handleSetLoading = (loading: boolean) => {
+ setIsLoading(loading);
+ };
+
+ const { sentinelRef, refresh, loadMore, totalCount } =
+ useFindingGroupResources({
+ checkId: group.checkId,
+ hasDateOrScanFilter: hasHistoricalData,
+ filters,
+ onSetResources: handleSetResources,
+ onAppendResources: handleAppendResources,
+ onSetLoading: handleSetLoading,
+ scrollContainerRef,
+ });
+
+ const drawer = useResourceDetailDrawer({
+ resources,
+ checkId: group.checkId,
+ totalResourceCount: totalCount ?? group.resourcesTotal,
+ onRequestMoreResources: loadMore,
+ });
+
+ const handleDrawerMuteComplete = () => {
+ drawer.refetchCurrent();
+ refresh();
+ };
+
+ const selectedFindingIds = Object.keys(rowSelection)
+ .filter((key) => rowSelection[key])
+ .map((idx) => resources[parseInt(idx)]?.findingId)
+ .filter((id): id is string => Boolean(id));
+
+ const selectableRowCount = resources.filter(canMuteFindingResource).length;
+
+ const getRowCanSelect = (row: Row): boolean => {
+ return canMuteFindingResource(row.original);
+ };
+
+ const clearSelection = () => {
+ setRowSelection({});
+ onResourceSelectionChange?.([]);
+ };
+
+ const isSelected = (id: string) => {
+ return selectedFindingIds.includes(id);
+ };
+
+ const handleMuteComplete = () => {
+ clearSelection();
+ refresh();
+ };
+
+ const handleRowSelectionChange = (
+ updater:
+ | RowSelectionState
+ | ((prev: RowSelectionState) => RowSelectionState),
+ ) => {
+ const newSelection =
+ typeof updater === "function" ? updater(rowSelection) : updater;
+ setRowSelection(newSelection);
+
+ if (onResourceSelectionChange) {
+ const newFindingIds = Object.keys(newSelection)
+ .filter((key) => newSelection[key])
+ .map((idx) => resources[parseInt(idx)]?.findingId)
+ .filter((id): id is string => Boolean(id));
+ onResourceSelectionChange(newFindingIds);
+ }
+ };
+
+ const resolveSelectedFindingIds = async (ids: string[]) => {
+ return ids.filter(Boolean);
+ };
+
+ return {
+ rowSelection,
+ resources,
+ isLoading,
+ sentinelRef,
+ refresh,
+ loadMore,
+ totalCount,
+ drawer,
+ handleDrawerMuteComplete,
+ selectedFindingIds,
+ selectableRowCount,
+ getRowCanSelect,
+ clearSelection,
+ isSelected,
+ handleMuteComplete,
+ handleRowSelectionChange,
+ resolveSelectedFindingIds,
+ };
+}
diff --git a/ui/hooks/use-finding-group-resources.test.ts b/ui/hooks/use-finding-group-resources.test.ts
new file mode 100644
index 0000000000..c2fe2e4145
--- /dev/null
+++ b/ui/hooks/use-finding-group-resources.test.ts
@@ -0,0 +1,206 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useFindingGroupResources } from "./use-finding-group-resources";
+
+type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
+
+let latestObserverCallback: IntersectionCallback | null = null;
+
+class MockIntersectionObserver {
+ callback: IntersectionCallback;
+ constructor(callback: IntersectionCallback) {
+ this.callback = callback;
+ latestObserverCallback = callback;
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {
+ if (latestObserverCallback === this.callback) {
+ latestObserverCallback = null;
+ }
+ }
+}
+
+function triggerIntersection() {
+ latestObserverCallback?.([
+ { isIntersecting: true } as IntersectionObserverEntry,
+ ]);
+}
+
+const findingGroupActionsMock = vi.hoisted(() => ({
+ getLatestFindingGroupResources: vi.fn(),
+ getFindingGroupResources: vi.fn(),
+ adaptFindingGroupResourcesResponse: vi.fn(),
+}));
+
+vi.mock("@/actions/finding-groups", () => findingGroupActionsMock);
+
+function makeApiResponse(
+ resources: { id: string }[],
+ { pages = 1 }: { pages?: number } = {},
+) {
+ return {
+ data: resources,
+ meta: { pagination: { pages } },
+ };
+}
+
+function fakeResource(id: string) {
+ return {
+ findingId: id,
+ resourceUid: `uid-${id}`,
+ resourceName: `Resource ${id}`,
+ status: "FAIL",
+ severity: "high",
+ isMuted: false,
+ };
+}
+
+function defaultOptions(overrides?: Record) {
+ return {
+ checkId: "check_1",
+ hasDateOrScanFilter: false,
+ filters: {},
+ onSetResources: vi.fn(),
+ onAppendResources: vi.fn(),
+ onSetLoading: vi.fn(),
+ ...overrides,
+ };
+}
+
+async function flushAsync() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+describe("useFindingGroupResources", () => {
+ beforeEach(() => {
+ vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
+ for (const mockFn of Object.values(findingGroupActionsMock)) {
+ mockFn.mockReset();
+ }
+ });
+
+ describe("when mounting", () => {
+ it("should fetch page 1 and deliver resources via onSetResources", async () => {
+ const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], {
+ pages: 1,
+ });
+ const adapted = [fakeResource("r1"), fakeResource("r2")];
+
+ findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
+ apiResponse,
+ );
+ findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
+ adapted,
+ );
+
+ const onSetResources = vi.fn();
+ const onSetLoading = vi.fn();
+
+ renderHook(() =>
+ useFindingGroupResources(
+ defaultOptions({ onSetResources, onSetLoading }),
+ ),
+ );
+ await flushAsync();
+
+ expect(
+ findingGroupActionsMock.getLatestFindingGroupResources,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ checkId: "check_1",
+ page: 1,
+ pageSize: 10,
+ }),
+ );
+ expect(onSetResources).toHaveBeenCalledWith(adapted, false);
+ });
+
+ it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => {
+ const apiResponse = makeApiResponse([], { pages: 1 });
+ findingGroupActionsMock.getFindingGroupResources.mockResolvedValue(
+ apiResponse,
+ );
+ findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
+ [],
+ );
+
+ renderHook(() =>
+ useFindingGroupResources(defaultOptions({ hasDateOrScanFilter: true })),
+ );
+ await flushAsync();
+
+ expect(
+ findingGroupActionsMock.getFindingGroupResources,
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ findingGroupActionsMock.getLatestFindingGroupResources,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("should forward the active finding-group filters to the resources endpoint", async () => {
+ const apiResponse = makeApiResponse([], { pages: 1 });
+ const filters = {
+ "filter[status__in]": "PASS",
+ "filter[severity__in]": "medium",
+ "filter[provider_type__in]": "aws",
+ };
+ findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
+ apiResponse,
+ );
+ findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
+ [],
+ );
+
+ renderHook(() => useFindingGroupResources(defaultOptions({ filters })));
+ await flushAsync();
+
+ expect(
+ findingGroupActionsMock.getLatestFindingGroupResources,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ checkId: "check_1",
+ page: 1,
+ pageSize: 10,
+ filters,
+ }),
+ );
+ });
+ });
+
+ describe("when all resources fit in one page", () => {
+ it("should not fetch page 2 after page 1 completes", async () => {
+ const apiResponse = makeApiResponse(
+ [{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }],
+ { pages: 1 },
+ );
+ findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
+ apiResponse,
+ );
+ findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
+ [
+ fakeResource("r1"),
+ fakeResource("r2"),
+ fakeResource("r3"),
+ fakeResource("r4"),
+ ],
+ );
+
+ const { result } = renderHook(() =>
+ useFindingGroupResources(defaultOptions()),
+ );
+ await flushAsync();
+
+ act(() => triggerIntersection());
+ await flushAsync();
+
+ expect(
+ findingGroupActionsMock.getLatestFindingGroupResources,
+ ).toHaveBeenCalledTimes(1);
+ expect(result.current.totalCount).toBeNull();
+ });
+ });
+});
diff --git a/ui/hooks/use-infinite-resources.ts b/ui/hooks/use-finding-group-resources.ts
similarity index 64%
rename from ui/hooks/use-infinite-resources.ts
rename to ui/hooks/use-finding-group-resources.ts
index df710ebf10..7ef4813be0 100644
--- a/ui/hooks/use-infinite-resources.ts
+++ b/ui/hooks/use-finding-group-resources.ts
@@ -12,7 +12,7 @@ import { FindingResourceRow } from "@/types";
const RESOURCES_PAGE_SIZE = 10;
-interface UseInfiniteResourcesOptions {
+interface UseFindingGroupResourcesOptions {
checkId: string;
hasDateOrScanFilter: boolean;
filters: Record;
@@ -22,28 +22,17 @@ interface UseInfiniteResourcesOptions {
hasMore: boolean,
) => void;
onSetLoading: (loading: boolean) => void;
- /** Scroll container element for IntersectionObserver root. Defaults to viewport. */
scrollContainerRef?: React.RefObject;
}
-interface UseInfiniteResourcesReturn {
+interface UseFindingGroupResourcesReturn {
sentinelRef: (node: HTMLDivElement | null) => void;
- /** Reset pagination and re-fetch page 1 (e.g. after muting). */
refresh: () => void;
- /** Imperatively load the next page (e.g. from drawer navigation). */
loadMore: () => void;
- /** Total number of resources matching current filters (from API pagination). */
totalCount: number | null;
}
-/**
- * Hook for paginated infinite-scroll loading of finding group resources.
- *
- * Uses refs for all mutable state to avoid dependency chains that
- * cause infinite re-render loops. The parent component remounts this
- * hook via key-prop when checkId or filters change.
- */
-export function useInfiniteResources({
+export function useFindingGroupResources({
checkId,
hasDateOrScanFilter,
filters,
@@ -51,28 +40,21 @@ export function useInfiniteResources({
onAppendResources,
onSetLoading,
scrollContainerRef,
-}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn {
- // All mutable state in refs to break dependency chains
+}: UseFindingGroupResourcesOptions): UseFindingGroupResourcesReturn {
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
- // Start as `true` to block the IntersectionObserver from calling loadNextPage
- // before the initial fetch runs. Ref callbacks fire during commit (sync),
- // but useMountEffect fires after paint — the observer can sneak in between.
const isLoadingRef = useRef(true);
const currentCheckIdRef = useRef(checkId);
const controllerRef = useRef(null);
const observerRef = useRef(null);
const totalCountRef = useRef(null);
- // Store latest values in refs so the fetch function always reads current values
- // without being recreated on every render
const hasDateOrScanRef = useRef(hasDateOrScanFilter);
const filtersRef = useRef(filters);
const onSetResourcesRef = useRef(onSetResources);
const onAppendResourcesRef = useRef(onAppendResources);
const onSetLoadingRef = useRef(onSetLoading);
- // Keep refs in sync with latest props
currentCheckIdRef.current = checkId;
hasDateOrScanRef.current = hasDateOrScanFilter;
filtersRef.current = filters;
@@ -103,7 +85,6 @@ export function useInfiniteResources({
filters: filtersRef.current,
});
- // Discard stale response if aborted (e.g. Strict Mode remount)
if (signal.aborted) {
return;
}
@@ -116,10 +97,6 @@ export function useInfiniteResources({
const hasMore = page < totalPages;
totalCountRef.current = response?.meta?.pagination?.count ?? null;
- // Commit the page number only after a successful (non-aborted) fetch.
- // This prevents a premature pageRef increment from loadNextPage being
- // permanently committed if a concurrent abort fires before fetchPage
- // starts executing.
pageRef.current = page;
hasMoreRef.current = hasMore;
@@ -130,27 +107,20 @@ export function useInfiniteResources({
}
} catch (error) {
if (!signal.aborted) {
- console.error("Error fetching resources:", error);
+ console.error("Error fetching finding group resources:", error);
onSetLoadingRef.current(false);
}
} finally {
- // Only release the loading guard if this fetch wasn't aborted.
- // An aborted fetch (e.g. Strict Mode cleanup) must NOT reset the flag
- // while a subsequent fetch from the remount is still in flight.
if (!signal.aborted) {
isLoadingRef.current = false;
}
}
}
- // Fetch first page on mount — parent remounts via key-prop on checkId/filter changes
useMountEffect(() => {
const controller = new AbortController();
controllerRef.current = controller;
- // Release the loading guard so fetchPage can proceed.
- // This is synchronous with the fetchPage call below, so the observer
- // cannot sneak in between these two lines.
isLoadingRef.current = false;
fetchPage(1, false, checkId, controller.signal);
@@ -167,17 +137,13 @@ export function useInfiniteResources({
isLoadingRef.current ||
!signal ||
signal.aborted
- )
+ ) {
return;
+ }
- // Pass the next page number as an argument without pre-committing
- // pageRef.current. The fetchPage function commits pageRef.current = page
- // only after a successful (non-aborted) response, eliminating the race
- // where a concurrent abort would leave pageRef permanently incremented.
fetchPage(pageRef.current + 1, true, currentCheckIdRef.current, signal);
}
- // IntersectionObserver callback
function handleIntersection(entries: IntersectionObserverEntry[]) {
const [entry] = entries;
if (entry.isIntersecting) {
@@ -185,7 +151,6 @@ export function useInfiniteResources({
}
}
- // Set up observer when sentinel node changes
function sentinelRef(node: HTMLDivElement | null) {
if (observerRef.current) {
observerRef.current.disconnect();
@@ -201,7 +166,6 @@ export function useInfiniteResources({
}
}
- /** Imperatively reset and re-fetch page 1 without changing deps. */
function refresh() {
controllerRef.current?.abort();
const controller = new AbortController();
diff --git a/ui/hooks/use-infinite-resources.test.ts b/ui/hooks/use-infinite-resources.test.ts
deleted file mode 100644
index 618de56fba..0000000000
--- a/ui/hooks/use-infinite-resources.test.ts
+++ /dev/null
@@ -1,570 +0,0 @@
-import { act, renderHook } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-import { useInfiniteResources } from "./use-infinite-resources";
-
-// ---------------------------------------------------------------------------
-// IntersectionObserver mock (jsdom doesn't provide one)
-// ---------------------------------------------------------------------------
-
-type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
-
-/** Stores the latest observer callback so tests can trigger intersections. */
-let latestObserverCallback: IntersectionCallback | null = null;
-
-class MockIntersectionObserver {
- callback: IntersectionCallback;
- constructor(callback: IntersectionCallback) {
- this.callback = callback;
- latestObserverCallback = callback;
- }
- observe() {}
- unobserve() {}
- disconnect() {
- if (latestObserverCallback === this.callback) {
- latestObserverCallback = null;
- }
- }
-}
-
-/** Simulate the sentinel becoming visible in the scroll container. */
-function triggerIntersection() {
- latestObserverCallback?.([
- { isIntersecting: true } as IntersectionObserverEntry,
- ]);
-}
-
-// ---------------------------------------------------------------------------
-// Mocks
-// ---------------------------------------------------------------------------
-
-const findingGroupActionsMock = vi.hoisted(() => ({
- getLatestFindingGroupResources: vi.fn(),
- getFindingGroupResources: vi.fn(),
- adaptFindingGroupResourcesResponse: vi.fn(),
-}));
-
-vi.mock("@/actions/finding-groups", () => findingGroupActionsMock);
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function makeApiResponse(
- resources: { id: string }[],
- { pages = 1 }: { pages?: number } = {},
-) {
- return {
- data: resources,
- meta: { pagination: { pages } },
- };
-}
-
-function fakeResource(id: string) {
- return {
- findingId: id,
- resourceUid: `uid-${id}`,
- resourceName: `Resource ${id}`,
- status: "FAIL",
- severity: "high",
- isMuted: false,
- };
-}
-
-function defaultOptions(overrides?: Record) {
- return {
- checkId: "check_1",
- hasDateOrScanFilter: false,
- filters: {},
- onSetResources: vi.fn(),
- onAppendResources: vi.fn(),
- onSetLoading: vi.fn(),
- ...overrides,
- };
-}
-
-/** Flush all pending microtasks (awaits in fetchPage). */
-async function flushAsync() {
- await act(async () => {
- await new Promise((r) => setTimeout(r, 0));
- });
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe("useInfiniteResources", () => {
- beforeEach(() => {
- vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
- for (const mockFn of Object.values(findingGroupActionsMock)) {
- mockFn.mockReset();
- }
- });
-
- describe("when mounting", () => {
- it("should fetch page 1 and deliver resources via onSetResources", async () => {
- // Given
- const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], {
- pages: 1,
- });
- const adapted = [fakeResource("r1"), fakeResource("r2")];
-
- findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- adapted,
- );
-
- const onSetResources = vi.fn();
- const onSetLoading = vi.fn();
-
- // When
- renderHook(() =>
- useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
- );
- await flushAsync();
-
- // Then
- expect(
- findingGroupActionsMock.getLatestFindingGroupResources,
- ).toHaveBeenCalledWith(
- expect.objectContaining({
- checkId: "check_1",
- page: 1,
- pageSize: 10,
- }),
- );
- expect(onSetResources).toHaveBeenCalledWith(adapted, false);
- });
-
- it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => {
- // Given
- const apiResponse = makeApiResponse([], { pages: 1 });
- findingGroupActionsMock.getFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- [],
- );
-
- // When
- renderHook(() =>
- useInfiniteResources(defaultOptions({ hasDateOrScanFilter: true })),
- );
- await flushAsync();
-
- // Then
- expect(
- findingGroupActionsMock.getFindingGroupResources,
- ).toHaveBeenCalledTimes(1);
- expect(
- findingGroupActionsMock.getLatestFindingGroupResources,
- ).not.toHaveBeenCalled();
- });
-
- it("should forward the active finding-group filters to the resources endpoint", async () => {
- // Given
- const apiResponse = makeApiResponse([], { pages: 1 });
- const filters = {
- "filter[status__in]": "PASS",
- "filter[severity__in]": "medium",
- "filter[provider_type__in]": "aws",
- };
- findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- [],
- );
-
- // When
- renderHook(() => useInfiniteResources(defaultOptions({ filters })));
- await flushAsync();
-
- // Then
- expect(
- findingGroupActionsMock.getLatestFindingGroupResources,
- ).toHaveBeenCalledWith(
- expect.objectContaining({
- checkId: "check_1",
- page: 1,
- pageSize: 10,
- filters,
- }),
- );
- });
- });
-
- describe("when all resources fit in one page", () => {
- it("should not fetch page 2 after page 1 completes", async () => {
- // Given — API returns 4 resources, 1 page total
- const apiResponse = makeApiResponse(
- [{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }],
- { pages: 1 },
- );
- findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- [
- fakeResource("r1"),
- fakeResource("r2"),
- fakeResource("r3"),
- fakeResource("r4"),
- ],
- );
-
- // When
- const { result } = renderHook(() =>
- useInfiniteResources(defaultOptions()),
- );
- await flushAsync();
-
- // Attach sentinel so observer is created
- const sentinel = document.createElement("div");
- act(() => {
- result.current.sentinelRef(sentinel);
- });
-
- // Simulate observer firing (sentinel visible after page 1 loaded)
- act(() => {
- triggerIntersection();
- });
- await flushAsync();
-
- // Then — only page 1 was fetched, never page 2
- const calls =
- findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
- const pageNumbers = calls.map(
- (c: unknown[]) => (c[0] as { page: number }).page,
- );
- expect(pageNumbers.every((p: number) => p === 1)).toBe(true);
- });
- });
-
- describe("when aborted fetch races with active fetch", () => {
- it("should not reset isLoading when an aborted fetch resolves", async () => {
- // Given — simulate the Strict Mode race condition:
- // fetch1 starts, gets aborted, fetch2 starts, fetch1's finally runs
- const onSetResources = vi.fn();
- const onSetLoading = vi.fn();
-
- // fetch1 resolves slowly (after abort)
- let resolveFetch1: (v: unknown) => void;
- const fetch1Promise = new Promise((r) => {
- resolveFetch1 = r;
- });
-
- // fetch2 resolves normally
- const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
- const adapted = [fakeResource("r1")];
-
- let callCount = 0;
- findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
- () => {
- callCount++;
- if (callCount === 1) return fetch1Promise;
- return Promise.resolve(apiResponse);
- },
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- adapted,
- );
-
- // When — mount, abort (simulating cleanup), remount
- const { unmount } = renderHook(() =>
- useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })),
- );
-
- // Simulate Strict Mode: unmount triggers abort
- unmount();
-
- // Fetch1 resolves AFTER abort — its finally should NOT reset isLoading
- await act(async () => {
- resolveFetch1!(apiResponse);
- await new Promise((r) => setTimeout(r, 0));
- });
-
- // Then — onSetResources should NOT have been called by the aborted fetch
- // (the signal.aborted check returns early)
- expect(onSetResources).not.toHaveBeenCalled();
- });
- });
-
- describe("when sentinel triggers next page", () => {
- it("should fetch page 2 via onAppendResources when hasMore is true", async () => {
- // Given — page 1 has more pages
- const page1Response = makeApiResponse(
- Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
- { pages: 3 },
- );
- const page1Adapted = Array.from({ length: 10 }, (_, i) =>
- fakeResource(`r${i}`),
- );
-
- const page2Response = makeApiResponse(
- Array.from({ length: 10 }, (_, i) => ({ id: `r${10 + i}` })),
- { pages: 3 },
- );
- const page2Adapted = Array.from({ length: 10 }, (_, i) =>
- fakeResource(`r${10 + i}`),
- );
-
- findingGroupActionsMock.getLatestFindingGroupResources
- .mockResolvedValueOnce(page1Response)
- .mockResolvedValueOnce(page2Response);
- findingGroupActionsMock.adaptFindingGroupResourcesResponse
- .mockReturnValueOnce(page1Adapted)
- .mockReturnValueOnce(page2Adapted);
-
- const onSetResources = vi.fn();
- const onAppendResources = vi.fn();
-
- // When — mount and wait for page 1
- const { result } = renderHook(() =>
- useInfiniteResources(
- defaultOptions({ onSetResources, onAppendResources }),
- ),
- );
- await flushAsync();
-
- expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
-
- // Attach sentinel and simulate intersection → triggers page 2
- const sentinel = document.createElement("div");
- act(() => {
- result.current.sentinelRef(sentinel);
- });
- act(() => {
- triggerIntersection();
- });
- await flushAsync();
-
- // Then
- expect(onAppendResources).toHaveBeenCalledWith(page2Adapted, true);
- expect(
- findingGroupActionsMock.getLatestFindingGroupResources,
- ).toHaveBeenCalledTimes(2);
- });
- });
-
- describe("when refresh is called", () => {
- it("should re-fetch page 1 and deliver via onSetResources", async () => {
- // Given
- const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 });
- const adapted = [fakeResource("r1")];
-
- findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- adapted,
- );
-
- const onSetResources = vi.fn();
-
- const { result } = renderHook(() =>
- useInfiniteResources(defaultOptions({ onSetResources })),
- );
- await flushAsync();
-
- expect(onSetResources).toHaveBeenCalledTimes(1);
-
- // When — refresh (e.g. after muting)
- act(() => {
- result.current.refresh();
- });
- await flushAsync();
-
- // Then — page 1 fetched again
- expect(onSetResources).toHaveBeenCalledTimes(2);
- const calls =
- findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
- expect(calls).toHaveLength(2);
- expect(calls[0][0].page).toBe(1);
- expect(calls[1][0].page).toBe(1);
- });
- });
-
- describe("when filters include search params", () => {
- it("should pass filters to the fetch function", async () => {
- // Given
- const apiResponse = makeApiResponse([], { pages: 1 });
- findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue(
- apiResponse,
- );
- findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue(
- [],
- );
-
- const filters = {
- "filter[name__icontains]": "my-resource",
- "filter[severity__in]": "high",
- };
-
- // When
- renderHook(() => useInfiniteResources(defaultOptions({ filters })));
- await flushAsync();
-
- // Then
- expect(
- findingGroupActionsMock.getLatestFindingGroupResources,
- ).toHaveBeenCalledWith(expect.objectContaining({ filters }));
- });
- });
-
- describe("when refresh() fires while loadNextPage is in-flight (race condition — Fix 5)", () => {
- it("should discard in-flight page 2 and fetch page 1 when refresh fires during loadNextPage", async () => {
- // Given — page 1 has 2 pages total, page 2 hangs indefinitely
- const page1Response = makeApiResponse(
- Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })),
- { pages: 2 },
- );
- const page1Adapted = Array.from({ length: 10 }, (_, i) =>
- fakeResource(`r${i}`),
- );
-
- const page2Response = makeApiResponse(
- Array.from({ length: 5 }, (_, i) => ({ id: `r${10 + i}` })),
- { pages: 2 },
- );
-
- const refreshPage1Response = makeApiResponse([{ id: "r-fresh-1" }], {
- pages: 1,
- });
- const refreshPage1Adapted = [fakeResource("r-fresh-1")];
-
- // page 2 hangs until we explicitly resolve it
- let resolveNextPage: (v: unknown) => void = () => {};
- const hangingPage2 = new Promise((r) => {
- resolveNextPage = r;
- });
-
- let callCount = 0;
- findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation(
- (args: { page: number }) => {
- callCount++;
- if (callCount === 1) {
- return Promise.resolve(page1Response);
- }
- if (args.page === 2) {
- return hangingPage2;
- }
- return Promise.resolve(refreshPage1Response);
- },
- );
-
- findingGroupActionsMock.adaptFindingGroupResourcesResponse
- .mockReturnValueOnce(page1Adapted)
- .mockReturnValue(refreshPage1Adapted);
-
- const onSetResources = vi.fn();
- const onAppendResources = vi.fn();
-
- // When — mount and wait for page 1
- const { result } = renderHook(() =>
- useInfiniteResources(
- defaultOptions({ onSetResources, onAppendResources }),
- ),
- );
- await flushAsync();
-
- expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true);
-
- // Trigger loadNextPage (increments pageRef to 2 in buggy code)
- const sentinel = document.createElement("div");
- act(() => {
- result.current.sentinelRef(sentinel);
- });
- act(() => {
- triggerIntersection();
- });
- // Do NOT flush — page 2 is hanging in-flight
-
- // Refresh fires while page 2 is in-flight
- act(() => {
- result.current.refresh();
- });
- await flushAsync();
-
- // Resolve hanging page 2 after refresh (simulates late stale response)
- await act(async () => {
- resolveNextPage(page2Response);
- await new Promise((r) => setTimeout(r, 0));
- });
-
- // Then — the aborted page 2 must NOT deliver resources (signal.aborted check)
- expect(onAppendResources).not.toHaveBeenCalled();
-
- // The refresh must have fetched page 1 and delivered fresh resources
- expect(onSetResources).toHaveBeenCalledWith(refreshPage1Adapted, false);
-
- // The refresh call must request page=1 (not page=3 due to stale pageRef)
- // Exact call sequence: [0]=initial page 1, [1]=loadNextPage page 2, [2]=refresh page 1
- const calls =
- findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
- expect((calls[0][0] as { page: number }).page).toBe(1); // initial fetch
- expect((calls[1][0] as { page: number }).page).toBe(2); // loadNextPage
- expect((calls[2][0] as { page: number }).page).toBe(1); // refresh
- });
-
- it("should fetch sequential pages without skipping when loadNextPage is used normally", async () => {
- // Given — page 1 has 3 pages; pages load sequentially
- const makePageResponse = (startIdx: number, total: number) =>
- makeApiResponse(
- Array.from({ length: 5 }, (_, i) => ({ id: `r${startIdx + i}` })),
- { pages: total },
- );
-
- findingGroupActionsMock.getLatestFindingGroupResources
- .mockResolvedValueOnce(makePageResponse(0, 3)) // page 1
- .mockResolvedValueOnce(makePageResponse(5, 3)) // page 2
- .mockResolvedValueOnce(makePageResponse(10, 3)); // page 3
-
- findingGroupActionsMock.adaptFindingGroupResourcesResponse
- .mockReturnValueOnce(
- Array.from({ length: 5 }, (_, i) => fakeResource(`r${i}`)),
- )
- .mockReturnValueOnce(
- Array.from({ length: 5 }, (_, i) => fakeResource(`r${5 + i}`)),
- )
- .mockReturnValueOnce(
- Array.from({ length: 5 }, (_, i) => fakeResource(`r${10 + i}`)),
- );
-
- const onAppendResources = vi.fn();
-
- // When — mount and wait for page 1
- const { result } = renderHook(() =>
- useInfiniteResources(defaultOptions({ onAppendResources })),
- );
- await flushAsync();
-
- // Attach sentinel
- const sentinel = document.createElement("div");
- act(() => {
- result.current.sentinelRef(sentinel);
- });
-
- // Load page 2
- act(() => {
- triggerIntersection();
- });
- await flushAsync();
-
- // Load page 3
- act(() => {
- triggerIntersection();
- });
- await flushAsync();
-
- // Then — pages were fetched in order: 2, 3 (not 2, 4 due to double-increment)
- const calls =
- findingGroupActionsMock.getLatestFindingGroupResources.mock.calls;
- expect(calls[1][0].page).toBe(2);
- expect(calls[2][0].page).toBe(3);
- });
- });
-});
diff --git a/ui/lib/finding-detail.ts b/ui/lib/finding-detail.ts
new file mode 100644
index 0000000000..69c0224297
--- /dev/null
+++ b/ui/lib/finding-detail.ts
@@ -0,0 +1,62 @@
+import { createDict } from "@/lib";
+import type { FindingProps } from "@/types/components";
+import type { FindingResourceRow } from "@/types/findings-table";
+import type { ProviderType } from "@/types/providers";
+
+interface JsonApiFindingResponse {
+ data?: FindingProps;
+ included?: Record[];
+}
+
+export function expandFindingWithRelationships(
+ apiResponse: JsonApiFindingResponse | undefined,
+): FindingProps | null {
+ if (!apiResponse?.data) {
+ return null;
+ }
+
+ const resourceDict = createDict("resources", apiResponse);
+ const scanDict = createDict("scans", apiResponse);
+ const providerDict = createDict("providers", apiResponse);
+
+ const finding = apiResponse.data;
+ const scan = scanDict[finding.relationships?.scan?.data?.id];
+ const resource =
+ resourceDict[finding.relationships?.resources?.data?.[0]?.id];
+ const provider = providerDict[scan?.relationships?.provider?.data?.id];
+
+ return {
+ ...finding,
+ relationships: { ...finding.relationships, scan, resource, provider },
+ } as FindingProps;
+}
+
+export function findingToFindingResourceRow(
+ finding: FindingProps,
+): FindingResourceRow {
+ const resource = finding.relationships?.resource?.attributes;
+ const provider = finding.relationships?.provider?.attributes;
+
+ return {
+ id: finding.id,
+ rowType: "resource",
+ findingId: finding.id,
+ checkId: finding.attributes.check_id,
+ providerType: (provider?.provider || "aws") as ProviderType,
+ providerAlias: provider?.alias || "-",
+ providerUid: provider?.uid || "-",
+ resourceName: resource?.name || "-",
+ resourceType: resource?.type || "-",
+ resourceGroup: "-",
+ resourceUid: resource?.uid || "-",
+ service: resource?.service || "-",
+ region: resource?.region || "-",
+ severity: finding.attributes.severity,
+ status: finding.attributes.status,
+ delta: finding.attributes.delta,
+ isMuted: finding.attributes.muted,
+ mutedReason: finding.attributes.muted_reason,
+ firstSeenAt: finding.attributes.first_seen_at,
+ lastSeenAt: finding.attributes.updated_at,
+ };
+}