diff --git a/ui/app/(prowler)/issues/[id]/page.test.tsx b/ui/app/(prowler)/issues/[id]/page.test.tsx
index 1564ae0818..ef7a0174fe 100644
--- a/ui/app/(prowler)/issues/[id]/page.test.tsx
+++ b/ui/app/(prowler)/issues/[id]/page.test.tsx
@@ -13,8 +13,8 @@ vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
-vi.mock("@/components/shared/feature-flag-gate", () => ({
- FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
+vi.mock("@/components/shared/feature-flag-route", () => ({
+ FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
diff --git a/ui/app/(prowler)/issues/[id]/page.tsx b/ui/app/(prowler)/issues/[id]/page.tsx
index b94e3cb06e..dcb6cc0540 100644
--- a/ui/app/(prowler)/issues/[id]/page.tsx
+++ b/ui/app/(prowler)/issues/[id]/page.tsx
@@ -9,7 +9,7 @@ import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
-import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
@@ -34,14 +34,7 @@ export default async function IssuePage({
return (
}>
-
- Issues is not available for this account yet.
-
- }
- >
+
-
+
);
}
diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx
index 9cf4e42018..6697a04c4b 100644
--- a/ui/app/(prowler)/issues/page.tsx
+++ b/ui/app/(prowler)/issues/page.tsx
@@ -7,7 +7,7 @@ import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/shadcn/content-layout";
-import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -29,16 +29,9 @@ export default async function Issues({
return (
}>
- {/* Deployment gate above (notFound); per-account rollout here. The route
- stays reachable by URL, so the off state explains itself. */}
-
- Issues is not available for this account yet.
-
- }
- >
+ {/* Deployment gate above (notFound); per-account rollout here, also a
+ redirect to the overview when off, like the hidden menu entry. */}
+
@@ -62,7 +55,7 @@ export default async function Issues({
}
/>
-
+
);
}
diff --git a/ui/components/issues/issue-attack-path.test.tsx b/ui/components/issues/issue-attack-path.test.tsx
new file mode 100644
index 0000000000..df6d818c47
--- /dev/null
+++ b/ui/components/issues/issue-attack-path.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { IssueAttributes } from "@/types/issues";
+
+const { getIssue, getIssueFindings } = vi.hoisted(() => ({
+ getIssue: vi.fn(),
+ getIssueFindings: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
+vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
+vi.mock("@/components/issues/issue-graph", () => ({
+ IssueGraph: () =>
graph
,
+}));
+
+import { IssueAttackPath } from "./issue-attack-path";
+
+const issue = { legs: {} } as IssueAttributes;
+
+describe("IssueAttackPath", () => {
+ it("says there is nothing to draw, not a load error, without resources", async () => {
+ // Given: no resources, and findings that cannot load without a scan
+ getIssue.mockResolvedValue({
+ data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
+ });
+
+ // When
+ render();
+
+ // Then
+ expect(
+ await screen.findByText("This issue has no affected resources to draw."),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
+ });
+
+ it("offers a retry when the findings for the drawn resources fail", async () => {
+ // Given
+ getIssue.mockResolvedValue({
+ data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
+ });
+
+ // When
+ render();
+
+ // Then
+ expect(
+ await screen.findByText("Could not load the attack path for this issue."),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
+ });
+});
diff --git a/ui/components/issues/issue-attack-path.tsx b/ui/components/issues/issue-attack-path.tsx
new file mode 100644
index 0000000000..af10850f4b
--- /dev/null
+++ b/ui/components/issues/issue-attack-path.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { IssueGraph } from "@/components/issues/issue-graph";
+import { IssuesLoadError } from "@/components/issues/issues-load-error";
+import { useIssueEvidence } from "@/hooks/use-issue-evidence";
+import { IssueAttributes } from "@/types/issues";
+
+interface IssueAttackPathProps {
+ id: string;
+ issue: IssueAttributes;
+ scanId?: string;
+ providerId?: string;
+}
+
+/** The issue's graph for the side panel; findings open on the full page. */
+export const IssueAttackPath = ({
+ id,
+ issue,
+ scanId,
+ providerId,
+}: IssueAttackPathProps) => {
+ const { state, retry } = useIssueEvidence(id, scanId, providerId);
+
+ if (state.status === "loading") {
+ return (
+
+ Loading the attack path…
+
+ );
+ }
+ // Nothing to draw beats a findings error: without resources there is no
+ // graph for the findings to land on.
+ if (state.status === "loaded" && state.resources.length === 0) {
+ return (
+