mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
refactor(ui): remove legacy side drawers and clean code (#10692)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
getLatestResources,
|
||||
getMetadataInfo,
|
||||
getResourceById,
|
||||
getResourceDrawerData,
|
||||
getResourceEvents,
|
||||
getResources,
|
||||
} from "./resources";
|
||||
|
||||
@@ -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 ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
<DataTable
|
||||
key={`dashboard-findings-${Date.now()}`}
|
||||
columns={ColumnNewFindingsToDate}
|
||||
columns={ColumnLatestFindings}
|
||||
data={(expandedResponse?.data || []) as FindingProps[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+93
@@ -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 }) => <div>{children}</div>,
|
||||
SheetContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SheetDescription: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SheetTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./node-overview", () => ({
|
||||
NodeOverview: () => <div>Node overview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./node-findings", () => ({
|
||||
NodeFindings: () => <div>Node findings</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./node-resources", () => ({
|
||||
NodeResources: () => <div>Node resources</div>,
|
||||
}));
|
||||
|
||||
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(<NodeDetailPanel node={findingNode} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /view finding finding-123/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<NodeDetailPanel node={resourceNode} />);
|
||||
|
||||
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(
|
||||
<NodeDetailPanel node={findingNode} onViewFinding={onViewFinding} />,
|
||||
);
|
||||
|
||||
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(<NodeDetailPanel node={findingNode} viewFindingLoading />);
|
||||
|
||||
const button = screen.getByRole("button", {
|
||||
name: /view finding finding-123/i,
|
||||
});
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByLabelText("Loading")).toHaveClass("size-4");
|
||||
});
|
||||
});
|
||||
+35
-11
@@ -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 = ({
|
||||
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||
Findings connected to this node
|
||||
</div>
|
||||
<NodeFindings node={node} allNodes={allNodes} />
|
||||
<NodeFindings
|
||||
node={node}
|
||||
allNodes={allNodes}
|
||||
onViewFinding={onViewFinding}
|
||||
viewFindingLoading={viewFindingLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -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 (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose?.()}>
|
||||
@@ -107,15 +122,19 @@ export const NodeDetailPanel = ({
|
||||
</SheetDescription>
|
||||
</div>
|
||||
{node && isProwlerFinding && (
|
||||
<Button asChild variant="default" size="sm" className="mt-1">
|
||||
<a
|
||||
href={`/findings?id=${String(node.properties?.id || node.id)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View finding ${String(node.properties?.id || node.id)}`}
|
||||
>
|
||||
View Finding →
|
||||
</a>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => onViewFinding?.(findingId)}
|
||||
disabled={viewFindingLoading}
|
||||
aria-label={`View finding ${findingId}`}
|
||||
>
|
||||
{viewFindingLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Finding →"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -123,7 +142,12 @@ export const NodeDetailPanel = ({
|
||||
|
||||
{node && (
|
||||
<div className="pt-6">
|
||||
<NodeDetailContent node={node} allNodes={allNodes} />
|
||||
<NodeDetailContent
|
||||
node={node}
|
||||
allNodes={allNodes}
|
||||
onViewFinding={onViewFinding}
|
||||
viewFindingLoading={viewFindingLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
||||
+21
-7
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { SeverityBadge } from "@/components/ui/table/severity-badge";
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
@@ -16,13 +18,20 @@ type Severity = (typeof SEVERITY_LEVELS)[keyof typeof SEVERITY_LEVELS];
|
||||
interface NodeFindingsProps {
|
||||
node: GraphNode;
|
||||
allNodes?: GraphNode[];
|
||||
onViewFinding?: (findingId: string) => void;
|
||||
viewFindingLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node findings section showing related findings for the selected node
|
||||
* Displays findings that are connected to the node via HAS_FINDING edges
|
||||
*/
|
||||
export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => {
|
||||
export const NodeFindings = ({
|
||||
node,
|
||||
allNodes = [],
|
||||
onViewFinding,
|
||||
viewFindingLoading = false,
|
||||
}: NodeFindingsProps) => {
|
||||
// Get finding IDs from the node's findings array (populated by adapter)
|
||||
const findingIds = node.findings || [];
|
||||
|
||||
@@ -79,15 +88,20 @@ export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => {
|
||||
ID: {findingId}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/findings?id=${findingId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => onViewFinding?.(findingId)}
|
||||
disabled={viewFindingLoading}
|
||||
aria-label={`View full finding for ${findingName}`}
|
||||
className="text-text-info dark:text-text-info h-auto shrink-0 p-0 text-xs font-medium hover:underline"
|
||||
>
|
||||
View Full Finding →
|
||||
</a>
|
||||
{viewFindingLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Full Finding →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{finding.properties?.description && (
|
||||
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary mt-2 text-xs">
|
||||
|
||||
+21
-10
@@ -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) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Link
|
||||
href={`/findings?id=${finding.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => 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 →
|
||||
</Link>
|
||||
{viewFindingLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Full Finding →"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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"),
|
||||
) && (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a
|
||||
href={`/findings?id=${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`View finding ${String(graphState.selectedNode.properties?.id || graphState.selectedNode.id)}`}
|
||||
>
|
||||
View Finding →
|
||||
</a>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleViewFinding(getFindingId(graphState.selectedNode))
|
||||
}
|
||||
disabled={finding.findingDetailLoading}
|
||||
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
|
||||
>
|
||||
{finding.findingDetailLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Finding"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -710,9 +727,22 @@ export default function AttackPathsPage() {
|
||||
<NodeDetailContent
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{finding.findingDetails && (
|
||||
<FindingDetailDrawer
|
||||
key={finding.findingDetails.id}
|
||||
finding={finding.findingDetails}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
if (!open) finding.resetFindingDetails();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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=<uuid>) 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 }),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
@@ -159,12 +160,7 @@ export const ClientAccordionContent = ({
|
||||
<h4 className="mb-2 text-sm font-medium">Findings</h4>
|
||||
|
||||
<DataTable
|
||||
// Remove select and updated_at columns for compliance view
|
||||
columns={getColumnFindings({}, 0).filter(
|
||||
(col) =>
|
||||
col.id !== "select" &&
|
||||
!("accessorKey" in col && col.accessorKey === "updated_at"),
|
||||
)}
|
||||
columns={getStandaloneFindingColumns({ openFindingId })}
|
||||
data={expandedFindings || []}
|
||||
metadata={findings?.meta}
|
||||
disableScroll={true}
|
||||
|
||||
+40
-113
@@ -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 (
|
||||
<FindingDetail
|
||||
findingDetails={row.original}
|
||||
defaultOpen={isOpen}
|
||||
<FindingDetailDrawer
|
||||
finding={finding}
|
||||
defaultOpen={defaultOpen}
|
||||
trigger={
|
||||
<div className="max-w-[500px]">
|
||||
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
|
||||
{checktitle}
|
||||
{finding.attributes.check_metadata.checktitle}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Function to generate columns with access to selection state
|
||||
export function getColumnFindings(
|
||||
rowSelection: RowSelectionState,
|
||||
selectableRowCount: number,
|
||||
): ColumnDef<FindingProps>[] {
|
||||
// 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<FindingProps>[] {
|
||||
const columns: ColumnDef<FindingProps>[] = [
|
||||
{
|
||||
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 (
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
isMuted={isMuted}
|
||||
mutedReason={mutedReason}
|
||||
isMuted={finding.attributes.muted}
|
||||
mutedReason={finding.attributes.muted_reason}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
table.toggleAllPageRowsSelected(checked === true)
|
||||
}
|
||||
aria-label="Select all"
|
||||
disabled={selectableRowCount === 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const finding = row.original;
|
||||
const isMuted = finding.attributes.muted;
|
||||
const isSelected = !!rowSelection[row.id];
|
||||
|
||||
return (
|
||||
<div className="ml-1 flex w-6 items-center justify-center pr-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isMuted}
|
||||
onCheckedChange={(checked) =>
|
||||
row.toggleSelected(checked === true)
|
||||
}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
// Status column
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
@@ -162,7 +104,6 @@ export function getColumnFindings(
|
||||
return <StatusFindingBadge status={status} />;
|
||||
},
|
||||
},
|
||||
// Finding column - clickable to open detail sheet
|
||||
{
|
||||
accessorKey: "check",
|
||||
header: ({ column }) => (
|
||||
@@ -172,9 +113,13 @@ export function getColumnFindings(
|
||||
param="check_id"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <FindingTitleCell row={row} />,
|
||||
cell: ({ row }) => (
|
||||
<FindingTitleCell
|
||||
finding={row.original}
|
||||
defaultOpen={openFindingId === row.original.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// 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 <SeverityBadge severity={severity} />;
|
||||
},
|
||||
},
|
||||
// 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 }) => (
|
||||
// <DataTableColumnHeader column={column} title="Impacted Resources" />
|
||||
// ),
|
||||
// cell: () => {
|
||||
// return <ImpactedResourcesCell impacted={1} total={1} />;
|
||||
// },
|
||||
// enableSorting: false,
|
||||
// },
|
||||
// Time column
|
||||
{
|
||||
];
|
||||
|
||||
if (includeUpdatedAt) {
|
||||
columns.push({
|
||||
accessorKey: "updated_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
@@ -287,13 +219,8 @@ export function getColumnFindings(
|
||||
} = getFindingsData(row);
|
||||
return <DateWithTime dateTime={updated_at} />;
|
||||
},
|
||||
},
|
||||
// Actions column - dropdown with Mute/Jira options
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
@@ -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 <FindingDetail findingDetails={findingDetails} />;
|
||||
};
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger ? (
|
||||
<button type="button" className="contents" onClick={handleOpen}>
|
||||
{trigger}
|
||||
</button>
|
||||
) : null}
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
isLoading={drawer.isLoading}
|
||||
isNavigating={drawer.isNavigating}
|
||||
checkMeta={drawer.checkMeta}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement> & {
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => <button {...props}>{children}</button>,
|
||||
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;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
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 }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
EntityInfo: () => <div data-testid="entity-info" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/severity-badge", () => ({
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/status-finding-badge", () => ({
|
||||
FindingStatus: {},
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
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 ? (
|
||||
<div data-testid="mute-modal">Muting {findingIds.length} finding(s)</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("../muted", () => ({
|
||||
Muted: ({ isMuted }: { isMuted: boolean }) =>
|
||||
isMuted ? <span data-testid="muted-badge">Muted</span> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./delta-indicator", () => ({
|
||||
DeltaIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
|
||||
EventsTimeline: () => <div data-testid="events-timeline" />,
|
||||
}));
|
||||
|
||||
vi.mock("react-markdown", () => ({
|
||||
default: ({ children }: { children: string }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
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(<FindingDetail findingDetails={baseFinding} />);
|
||||
|
||||
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(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /mute/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the mute modal when clicking the Mute button", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FindingDetail findingDetails={baseFinding} />);
|
||||
|
||||
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(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.queryByTestId("mute-modal")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the muted badge for muted findings", () => {
|
||||
const mutedFinding: FindingProps = {
|
||||
...baseFinding,
|
||||
attributes: { ...baseFinding.attributes, muted: true },
|
||||
};
|
||||
|
||||
render(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.getByTestId("muted-badge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[findingDetails.id]}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Row 1: Status badges */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<StatusFindingBadge status={attributes.status as FindingStatus} />
|
||||
<SeverityBadge severity={attributes.severity || "-"} />
|
||||
{attributes.delta && (
|
||||
<div className="flex items-center gap-1 capitalize">
|
||||
<DeltaIndicator delta={attributes.delta} />
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{attributes.delta}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Muted
|
||||
isMuted={attributes.muted}
|
||||
mutedReason={attributes.muted_reason || ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Title with copy link */}
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium">
|
||||
{renderValue(attributes.check_metadata.checktitle)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={copyFindingUrl}
|
||||
className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80"
|
||||
aria-label="Copy finding link to clipboard"
|
||||
>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy finding link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
</h2>
|
||||
|
||||
{/* Row 3: First Seen */}
|
||||
<div className="text-text-neutral-tertiary text-sm">
|
||||
<span className="text-text-neutral-secondary mr-1">Time:</span>
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs key={findingDetails.id} defaultValue="general" className="w-full">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{!attributes.muted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsMuteModalOpen(true)}
|
||||
>
|
||||
<VolumeX className="size-4" />
|
||||
Mute
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general" className="flex flex-col gap-4">
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
Here is an overview of this finding:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{providerDetails && (
|
||||
<EntityInfo
|
||||
cloudProvider={providerDetails.provider as ProviderType}
|
||||
entityAlias={providerDetails.alias}
|
||||
entityId={providerDetails.uid}
|
||||
showConnectionStatus={providerDetails.connection.connected}
|
||||
/>
|
||||
)}
|
||||
<InfoField label="Service">
|
||||
{attributes.check_metadata.servicename}
|
||||
</InfoField>
|
||||
<InfoField label="Region">{resource?.region ?? "-"}</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={attributes.check_id} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={findingDetails.id} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={attributes.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="simple">
|
||||
<DateWithTime inline dateTime={attributes.first_seen_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{attributes.status === "FAIL" && (
|
||||
<InfoField label="Risk" variant="simple">
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full rounded-md border p-2",
|
||||
"border-border-error-primary bg-bg-fail-secondary",
|
||||
)}
|
||||
>
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.risk}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
<InfoField label="Description">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.description}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
|
||||
<InfoField label="Status Extended">
|
||||
{renderValue(attributes.status_extended)}
|
||||
</InfoField>
|
||||
|
||||
{attributes.check_metadata.remediation && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-text-neutral-primary text-sm font-bold">
|
||||
Remediation Details
|
||||
</h4>
|
||||
|
||||
{/* Recommendation section */}
|
||||
{attributes.check_metadata.remediation.recommendation.text && (
|
||||
<InfoField label="Recommendation">
|
||||
<div className="flex flex-col gap-2">
|
||||
<MarkdownContainer>
|
||||
{
|
||||
attributes.check_metadata.remediation.recommendation
|
||||
.text
|
||||
}
|
||||
</MarkdownContainer>
|
||||
|
||||
{attributes.check_metadata.remediation.recommendation
|
||||
.url && (
|
||||
<CustomLink
|
||||
href={
|
||||
attributes.check_metadata.remediation.recommendation
|
||||
.url
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* CLI Command section */}
|
||||
{attributes.check_metadata.remediation.code.cli && (
|
||||
<InfoField label="CLI Command" variant="simple">
|
||||
<div
|
||||
className={cn("rounded-md p-2", "bg-bg-neutral-tertiary")}
|
||||
>
|
||||
<span className="text-xs whitespace-pre-line">
|
||||
{attributes.check_metadata.remediation.code.cli}
|
||||
</span>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Remediation Steps section */}
|
||||
{attributes.check_metadata.remediation.code.other && (
|
||||
<InfoField label="Remediation Steps">
|
||||
<MarkdownContainer>
|
||||
{attributes.check_metadata.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{/* Additional URLs section */}
|
||||
{attributes.check_metadata.additionalurls &&
|
||||
attributes.check_metadata.additionalurls.length > 0 && (
|
||||
<InfoField label="References">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{attributes.check_metadata.additionalurls.map(
|
||||
(link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoField label="Categories">
|
||||
{attributes.check_metadata.categories?.join(", ") || "none"}
|
||||
</InfoField>
|
||||
</TabsContent>
|
||||
|
||||
{/* Resources Tab */}
|
||||
<TabsContent value="resources" className="flex flex-col gap-4">
|
||||
{resource ? (
|
||||
<>
|
||||
{providerDetails?.provider === "iac" && gitUrl && (
|
||||
<div className="flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={gitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bg-data-info inline-flex items-center gap-1 text-sm"
|
||||
aria-label="Open resource in repository"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View in Repository
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Go to Resource in the Repository
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Resource Name">
|
||||
{renderValue(resource.name)}
|
||||
</InfoField>
|
||||
<InfoField label="Resource Type">
|
||||
{renderValue(resource.type)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Service">
|
||||
{renderValue(resource.service)}
|
||||
</InfoField>
|
||||
<InfoField label="Region">
|
||||
{renderValue(resource.region)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Partition">
|
||||
{renderValue(resource.partition)}
|
||||
</InfoField>
|
||||
<InfoField label="Details">
|
||||
{renderValue(resource.details)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<InfoField label="Resource ID" variant="simple">
|
||||
<CodeSnippet value={resource.uid} />
|
||||
</InfoField>
|
||||
|
||||
{resource.tags && Object.entries(resource.tags).length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-text-neutral-secondary text-sm font-bold">
|
||||
Tags
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Object.entries(resource.tags).map(([key, value]) => (
|
||||
<InfoField key={key} label={key}>
|
||||
{renderValue(value)}
|
||||
</InfoField>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Created At">
|
||||
<DateWithTime inline dateTime={resource.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated">
|
||||
<DateWithTime inline dateTime={resource.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
Resource information is not available.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Scans Tab */}
|
||||
<TabsContent value="scans" className="flex flex-col gap-4">
|
||||
{scan ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Scan Name">{scan.name || "N/A"}</InfoField>
|
||||
<InfoField label="Resources Scanned">
|
||||
{scan.unique_resource_count}
|
||||
</InfoField>
|
||||
<InfoField label="Progress">{scan.progress}%</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Trigger">{scan.trigger}</InfoField>
|
||||
<InfoField label="State">{scan.state}</InfoField>
|
||||
<InfoField label="Duration">
|
||||
{formatDuration(scan.duration)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Started At">
|
||||
<DateWithTime inline dateTime={scan.started_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Completed At">
|
||||
<DateWithTime inline dateTime={scan.completed_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Launched At">
|
||||
<DateWithTime inline dateTime={scan.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
{scan.scheduled_at && (
|
||||
<InfoField label="Scheduled At">
|
||||
<DateWithTime inline dateTime={scan.scheduled_at} />
|
||||
</InfoField>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
Scan information is not available.
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={finding.relationships?.resource?.id}
|
||||
isAwsProvider={providerDetails?.provider === "aws"}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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}
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Finding Details</DrawerTitle>
|
||||
<DrawerDescription>View the finding details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
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<FindingResourceRow>): 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({
|
||||
</Table>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading resources...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <LoadingState label="Loading resources..." />}
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
@@ -299,7 +231,7 @@ export function FindingsGroupDrillDown({
|
||||
selectedCount={selectedFindingIds.length}
|
||||
selectedFindingIds={selectedFindingIds}
|
||||
onBeforeOpen={async () => {
|
||||
return resolveResourceIds(selectedFindingIds);
|
||||
return resolveSelectedFindingIds(selectedFindingIds);
|
||||
}}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
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<string, string> = { ...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<FindingResourceRow>): 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({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Spinner for infinite scroll (subsequent pages only) */}
|
||||
{/* Loading state for infinite scroll (subsequent pages only) */}
|
||||
{isLoading && rows.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading resources...
|
||||
</span>
|
||||
</div>
|
||||
<LoadingState label="Loading resources..." />
|
||||
)}
|
||||
|
||||
{/* Sentinel for scroll hint detection */}
|
||||
|
||||
+2
-4
@@ -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 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
<LoadingState spinnerClassName="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
+50
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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<ResourceDrawerFinding[]>([]);
|
||||
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,
|
||||
|
||||
@@ -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<FindingProps>[] =
|
||||
getStandaloneFindingColumns({
|
||||
includeUpdatedAt: true,
|
||||
});
|
||||
@@ -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<FindingProps>[] = getColumnFindings(
|
||||
{} as RowSelectionState,
|
||||
0,
|
||||
).filter((column) => column.id !== "select" && column.id !== "actions");
|
||||
|
||||
export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = baseColumns;
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./column-new-findings-to-date";
|
||||
export * from "./column-latest-findings";
|
||||
export * from "./skeleton-table-new-findings";
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,9 @@ export const ResourceDetailsSheet = ({
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{open && <ResourceDetailContent resourceDetails={resource} />}
|
||||
{open && (
|
||||
<ResourceDetailContent key={resource.id} resourceDetails={resource} />
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const SkeletonFindingDetails = () => {
|
||||
return (
|
||||
<div className="dark:bg-prowler-blue-400 flex animate-pulse flex-col gap-6 rounded-lg p-4 shadow">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="bg-default-200 h-6 w-2/3 rounded" />
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="bg-default-200 h-5 w-6 rounded-full" />
|
||||
<div className="bg-default-200 h-6 w-20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
<div className="bg-default-200 h-4 w-20 rounded" />
|
||||
<div className="bg-default-200 h-5 w-40 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* InfoField Blocks */}
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-28 rounded" />
|
||||
<div className="bg-default-200 h-5 w-full rounded" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Risk and Description Sections */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-28 rounded" />
|
||||
<div className="bg-default-200 h-16 w-full rounded" />
|
||||
</div>
|
||||
|
||||
<div className="bg-default-200 h-4 w-36 rounded" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-24 rounded" />
|
||||
<div className="bg-default-200 h-5 w-2/3 rounded" />
|
||||
<div className="bg-default-200 h-4 w-24 rounded" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-28 rounded" />
|
||||
<div className="bg-default-200 h-10 w-full rounded" />
|
||||
</div>
|
||||
|
||||
{/* Additional Resources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-36 rounded" />
|
||||
<div className="bg-default-200 h-5 w-32 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-default-200 h-4 w-24 rounded" />
|
||||
<div className="bg-default-200 h-5 w-1/3 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Provider Info Section */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="bg-default-200 relative h-8 w-8 rounded-full">
|
||||
<div className="bg-default-300 absolute top-0 right-0 h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
<div className="flex max-w-[120px] flex-col gap-1">
|
||||
<div className="bg-default-200 h-4 w-full rounded" />
|
||||
<div className="bg-default-200 h-4 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const SkeletonFindingSummary = () => {
|
||||
return (
|
||||
<div className="dark:bg-prowler-blue-400 flex animate-pulse flex-col gap-4 rounded-lg p-4 shadow">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="bg-default-200 h-5 w-1/3 rounded" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-default-200 h-5 w-16 rounded" />
|
||||
<div className="bg-default-200 h-5 w-16 rounded" />
|
||||
<div className="bg-default-200 h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
@@ -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<OrganizationResource | null>(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<ResourceFinding[]>([]);
|
||||
const [findingsMetadata, setFindingsMetadata] =
|
||||
useState<MetaDataProps | null>(null);
|
||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||
const [findingsReloadNonce, setFindingsReloadNonce] = useState(0);
|
||||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeTab, setActiveTab] = useState("findings");
|
||||
const [metadataCopied, setMetadataCopied] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const findingFetchRef = useRef<AbortController | null>(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<ResourceFinding>): boolean =>
|
||||
@@ -396,14 +245,16 @@ export const ResourceDetailContent = ({
|
||||
/>
|
||||
|
||||
{findingDetailLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading finding details...
|
||||
</p>
|
||||
</div>
|
||||
<LoadingState label="Loading finding details..." />
|
||||
) : (
|
||||
findingDetails && <FindingDetail findingDetails={findingDetails} />
|
||||
findingDetails && (
|
||||
<FindingDetailDrawer
|
||||
key={findingDetails.id}
|
||||
finding={findingDetails}
|
||||
inline
|
||||
onMuteComplete={handleMuteComplete}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -543,12 +394,7 @@ export const ResourceDetailContent = ({
|
||||
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<TabsContent value="findings" className="flex flex-col gap-4">
|
||||
{findingsLoading && !hasInitiallyLoaded ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Loading findings...
|
||||
</p>
|
||||
</div>
|
||||
<LoadingState label="Loading findings..." />
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
@@ -563,13 +409,21 @@ export const ResourceDetailContent = ({
|
||||
getRowCanSelect={getRowCanSelect}
|
||||
controlledSearch={searchQuery}
|
||||
onSearchChange={(value) => {
|
||||
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 = ({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={resourceId}
|
||||
isAwsProvider={providerData.provider === "aws"}
|
||||
/>
|
||||
{activeTab === "events" && (
|
||||
<EventsTimeline
|
||||
resourceId={resourceId}
|
||||
isAwsProvider={providerData.provider === "aws"}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -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 <ResourceDetailContent resourceDetails={resourceDetails} />;
|
||||
}
|
||||
|
||||
// With trigger, wrap in Drawer - content only mounts when open (lazy loading)
|
||||
return (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 outline-none md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Resource Details</DrawerTitle>
|
||||
<DrawerDescription>View the resource details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{/* Content only renders when drawer is open - this is the key optimization */}
|
||||
{isOpen && <ResourceDetailContent resourceDetails={resourceDetails} />}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
resetFindingDetails: () => void;
|
||||
}
|
||||
|
||||
export function useFindingDetails(): UseFindingDetailsReturn {
|
||||
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
||||
null,
|
||||
);
|
||||
const [findingDetailLoading, setFindingDetailLoading] = useState(false);
|
||||
const findingFetchRef = useRef<AbortController | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
export function useResourceDrawerBootstrap({
|
||||
resourceId,
|
||||
resourceUid,
|
||||
providerId,
|
||||
providerType,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
findingsReloadNonce,
|
||||
}: UseResourceDrawerBootstrapOptions): UseResourceDrawerBootstrapReturn {
|
||||
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
||||
const [findingsMetadata, setFindingsMetadata] =
|
||||
useState<MetaDataProps | null>(null);
|
||||
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
||||
const [findingsLoading, setFindingsLoading] = useState(true);
|
||||
const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
|
||||
const [providerOrg, setProviderOrg] = useState<OrganizationResource | null>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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(<LoadingState />);
|
||||
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(<LoadingState />);
|
||||
expect(screen.queryByText(/loading/i, { selector: "span" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the label when provided", () => {
|
||||
render(<LoadingState label="Loading findings..." />);
|
||||
expect(screen.getByText("Loading findings...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards spinnerClassName to the Spinner so callers can override the size", () => {
|
||||
const { container } = render(<LoadingState spinnerClassName="size-5" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("class") ?? "").toContain("size-5");
|
||||
});
|
||||
|
||||
it("forwards className to the wrapper element", () => {
|
||||
const { container } = render(<LoadingState className="custom-wrapper" />);
|
||||
expect(container.firstChild).toHaveClass("custom-wrapper");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn("flex items-center justify-center gap-2 py-8", className)}
|
||||
>
|
||||
<Spinner className={cn("size-6", spinnerClassName)} />
|
||||
{label && (
|
||||
<span className="text-text-neutral-tertiary text-sm">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
@@ -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<ResourceEventProps[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorStatus, setErrorStatus] = useState<number | null>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(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);
|
||||
|
||||
@@ -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<ResourceEventProps[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorStatus, setErrorStatus] = useState<number | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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("<a");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { type AnchorHTMLAttributes, forwardRef, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib";
|
||||
|
||||
interface CustomLinkProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
interface CustomLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
target?: "_self" | "_blank" | string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
scroll?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
|
||||
function isExternalHref(href: string) {
|
||||
return /^https?:\/\//.test(href) || href.startsWith("mailto:");
|
||||
}
|
||||
|
||||
function hasDynamicHrefPlaceholder(href: string) {
|
||||
return href.includes("[") && href.includes("]");
|
||||
}
|
||||
|
||||
export const CustomLink = forwardRef<HTMLAnchorElement, CustomLinkProps>(
|
||||
(
|
||||
{
|
||||
href,
|
||||
@@ -28,6 +35,29 @@ export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const linkClassName = cn(
|
||||
`text-${size} text-button-tertiary p-0`,
|
||||
className,
|
||||
);
|
||||
const shouldUseAnchor =
|
||||
isExternalHref(href) || hasDynamicHrefPlaceholder(href);
|
||||
|
||||
if (shouldUseAnchor) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
href={href}
|
||||
aria-label={ariaLabel}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className={linkClassName}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
@@ -36,7 +66,7 @@ export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
|
||||
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}
|
||||
|
||||
@@ -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<string, string>;
|
||||
hasHistoricalData: boolean;
|
||||
onResourceSelectionChange?: (findingIds: string[]) => void;
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface UseFindingGroupResourceStateReturn {
|
||||
rowSelection: RowSelectionState;
|
||||
resources: FindingResourceRow[];
|
||||
isLoading: boolean;
|
||||
sentinelRef: (node: HTMLDivElement | null) => void;
|
||||
refresh: () => void;
|
||||
loadMore: () => void;
|
||||
totalCount: number | null;
|
||||
drawer: ReturnType<typeof useResourceDetailDrawer>;
|
||||
handleDrawerMuteComplete: () => void;
|
||||
selectedFindingIds: string[];
|
||||
selectableRowCount: number;
|
||||
getRowCanSelect: (row: Row<FindingResourceRow>) => boolean;
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
handleMuteComplete: () => void;
|
||||
handleRowSelectionChange: OnChangeFn<RowSelectionState>;
|
||||
resolveSelectedFindingIds: (ids: string[]) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export function useFindingGroupResourceState({
|
||||
group,
|
||||
filters,
|
||||
hasHistoricalData,
|
||||
onResourceSelectionChange,
|
||||
scrollContainerRef,
|
||||
}: UseFindingGroupResourceStateOptions): UseFindingGroupResourceStateReturn {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [resources, setResources] = useState<FindingResourceRow[]>([]);
|
||||
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<FindingResourceRow>): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { FindingResourceRow } from "@/types";
|
||||
|
||||
const RESOURCES_PAGE_SIZE = 10;
|
||||
|
||||
interface UseInfiniteResourcesOptions {
|
||||
interface UseFindingGroupResourcesOptions {
|
||||
checkId: string;
|
||||
hasDateOrScanFilter: boolean;
|
||||
filters: Record<string, string | string[] | undefined>;
|
||||
@@ -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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
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<AbortController | null>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const totalCountRef = useRef<number | null>(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();
|
||||
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user