diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 6186f3be16..288b27c807 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971) - Finding detail drawer now labels remediation actions from finding-level recommendation URLs by destination: "View CVE", "View in Prowler Hub", "View Advisory", or "View Reference", while keeping URL-only remediation cards labeled [(#10853)](https://github.com/prowler-cloud/prowler/pull/10853) +- Finding detail drawer reorganized: status-colored banner below the resource info, dedicated Remediation tab, renamed "Findings for this resource" tab, and inline View Resource link next to the resource UID [(#11091)](https://github.com/prowler-cloud/prowler/pull/11091) - ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975) --- diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index 7f260c8aa6..6b78bc189a 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -139,6 +139,7 @@ interface FindingGroupResourceAttributes { resource: ResourceInfo; provider: ProviderInfo; status: string; + status_extended?: string; muted?: boolean; delta?: string | null; severity: string; @@ -187,6 +188,7 @@ export function adaptFindingGroupResourcesResponse( region: item.attributes.resource?.region || "-", severity: (item.attributes.severity || "informational") as Severity, status: item.attributes.status, + statusExtended: item.attributes.status_extended, delta: item.attributes.delta || null, isMuted: item.attributes.muted ?? item.attributes.status === "MUTED", mutedReason: item.attributes.muted_reason || undefined, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index 1e451b6a34..ed09bb52e0 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -288,7 +288,8 @@ vi.mock("@/components/ui/entities/date-with-time", () => ({ })); vi.mock("@/components/ui/entities/entity-info", () => ({ - EntityInfo: () => null, + EntityInfo: ({ idAction }: { idAction?: ReactNode }) => + idAction ? {idAction} : null, })); vi.mock("@/components/ui/table", () => ({ @@ -427,7 +428,7 @@ const mockFinding: ResourceDrawerFinding = { }; describe("ResourceDetailDrawerContent — resource navigation", () => { - it("should render a View Resource link below the resource actions menu", () => { + it("should render a View Resource link inline next to the resource UID", () => { // Given render( { const viewResourceLink = screen.getByRole("link", { name: "View Resource", }); - const resourceActionsMenu = screen.getByRole("menu", { - name: "Resource actions", - }); // Then expect(viewResourceLink).toHaveAttribute( @@ -459,10 +457,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => { ); expect(viewResourceLink).toHaveAttribute("target", "_blank"); expect(viewResourceLink).toHaveAttribute("rel", "noopener noreferrer"); - expect( - resourceActionsMenu.compareDocumentPosition(viewResourceLink) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); }); }); const mockResourceRow: FindingResourceRow = { @@ -920,8 +914,8 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => { // Fix 5 & 6: Risk section has danger styling, sections have separators and bigger headings // --------------------------------------------------------------------------- -describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () => { - it("should wrap the Risk section in a Card component (data-slot='card')", () => { +describe("ResourceDetailDrawerContent — Risk section styling", () => { + it("should render the Risk section with a vertical accent border (no danger card)", () => { // Given const { container } = render( , ); - // When — find a Card with variant="danger" that contains the Risk label - const dangerCards = Array.from( - container.querySelectorAll('[data-variant="danger"]'), - ); - const riskCard = dangerCards.find((el) => - el.textContent?.includes("Risk:"), + // When — find the Risk heading and walk up to the section wrapper + const riskHeading = Array.from(container.querySelectorAll("span")).find( + (el) => el.textContent?.trim() === "Risk:", ); + const riskSection = riskHeading?.parentElement; - // Then — Risk section must be wrapped in a Card variant="danger" - expect(riskCard).toBeDefined(); + // Then — Risk wrapper has a left accent border, not a danger Card + expect(riskSection).toBeDefined(); + expect(riskSection?.className).toMatch(/border-l/); + expect(riskSection?.getAttribute("data-variant")).toBeNull(); }); it("should use larger heading size for section labels (text-sm → text-base or larger)", () => { @@ -1376,14 +1370,10 @@ describe("ResourceDetailDrawerContent — current resource row display", () => { // Then expect(screen.getByText("row-service")).toBeInTheDocument(); expect(screen.getByText("eu-west-1")).toBeInTheDocument(); - expect(screen.getByText("row-group")).toBeInTheDocument(); - expect(screen.getByText("row-type")).toBeInTheDocument(); expect(screen.getByText("FAIL")).toBeInTheDocument(); expect(screen.getByText("critical")).toBeInTheDocument(); expect(screen.queryByText("finding-service")).not.toBeInTheDocument(); expect(screen.queryByText("ap-south-1")).not.toBeInTheDocument(); - expect(screen.queryByText("finding-group")).not.toBeInTheDocument(); - expect(screen.queryByText("finding-type")).not.toBeInTheDocument(); }); it("should prefer the fetched finding status and severity in the header when the current row is stale", () => { @@ -1466,12 +1456,11 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () expect(screen.getByText("low")).toBeInTheDocument(); expect(screen.getByText("ec2")).toBeInTheDocument(); expect(screen.getByText("eu-west-1")).toBeInTheDocument(); - expect(screen.getByText("row-group")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Finding Overview" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Other Findings For This Resource" }), + screen.getByRole("button", { name: "Findings for this resource" }), ).toBeInTheDocument(); expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); expect(screen.queryByText("Status extended")).not.toBeInTheDocument(); @@ -1584,7 +1573,7 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () screen.getByRole("button", { name: "Finding Overview" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Other Findings For This Resource" }), + screen.getByRole("button", { name: "Findings for this resource" }), ).toBeInTheDocument(); }); @@ -1650,7 +1639,6 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () expect(screen.getByText("Started At")).toBeInTheDocument(); expect(screen.getByText("Completed At")).toBeInTheDocument(); expect(screen.getByText("Launched At")).toBeInTheDocument(); - expect(screen.getByText("Scheduled At")).toBeInTheDocument(); expect(screen.getByTestId("scans-navigation-skeleton")).toBeInTheDocument(); }); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 2a8745fd4f..073b1ff6e9 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -69,7 +69,6 @@ import { import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { getRegionFlag } from "@/lib/region-flags"; -import { cn } from "@/lib/utils"; import { getRecommendationLinkLabel } from "@/lib/vulnerability-references"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { FindingResourceRow } from "@/types/findings-table"; @@ -410,8 +409,6 @@ export function ResourceDetailDrawerContent({ const resourceUid = currentResource?.resourceUid ?? f?.resourceUid; const resourceService = currentResource?.service ?? f?.resourceService; const resourceRegion = currentResource?.region ?? f?.resourceRegion; - const resourceGroup = currentResource?.resourceGroup ?? f?.resourceGroup; - const resourceType = currentResource?.resourceType ?? f?.resourceType; const resourceRegionLabel = resourceRegion || "-"; const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null; const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null; @@ -429,7 +426,6 @@ export function ResourceDetailDrawerContent({ const regionFilter = searchParams.get("filter[region__in]"); const nativeIacConfig = resolveNativeIacConfig(providerType); const showOverviewCheckMetaContent = showCheckMetaContent; - const showOverviewFindingContent = Boolean(f); const resourceDetailHref = f?.resourceId ? buildResourceDetailHref(f.resourceId) : null; @@ -446,7 +442,8 @@ export function ResourceDetailDrawerContent({ label: getRecommendationLinkLabel(recommendationUrl), } : null; - const overviewStatusExtended = f?.statusExtended; + const overviewStatusExtended = + currentResource?.statusExtended || f?.statusExtended; const showOverviewStatusExtended = Boolean(overviewStatusExtended); const handleOpenCompliance = async (framework: string) => { @@ -678,83 +675,72 @@ export function ResourceDetailDrawerContent({ <>
{/* Resource info grid — 4 data columns */} -
- {/* Row 1: Account, Resource, Service, Region */} - } - entityAlias={providerAlias} - entityId={providerUid} - /> - } - entityAlias={resourceName} - entityId={resourceUid} - idLabel="UID" - /> - - {resourceService} - - - - {getRegionFlag(resourceRegionLabel) && ( - - {getRegionFlag(resourceRegionLabel)} - - )} - {resourceRegionLabel} - - - - {/* Row 2: Dates */} - - - - - - - - {getFailingForLabel(firstSeenAt) || "-"} - - - {resourceGroup || "-"} - - - {/* Row 3: IDs */} - - - - - {currentResource?.findingId || f?.id ? ( - + {/* Row 1: Account (cols 1-2), Resource (cols 3-5) */} +
+
+ + Account + + } + entityAlias={providerAlias} + entityId={providerUid} /> - ) : ( - - )} - - - {f?.uid ? ( - +
+ + Resource + + } + entityAlias={resourceName} + entityId={resourceUid} + idLabel="UID" + idAction={ + resourceDetailHref ? ( + + ) : undefined + } /> - ) : ( - - )} - +
+
- {/* Row 4: Resource metadata */} - - {resourceType || "-"} - + {/* Row 2: Last detected, First seen, Failing for, Service, Region */} +
+ + + + + + + + {getFailingForLabel(firstSeenAt) || "-"} + + + {resourceService} + + + + {getRegionFlag(resourceRegionLabel) && ( + + {getRegionFlag(resourceRegionLabel)} + + )} + {resourceRegionLabel} + + +
{/* Actions button — fixed size, aligned with row 1 */} @@ -788,19 +774,28 @@ export function ResourceDetailDrawerContent({
- {resourceDetailHref && ( -
- -
+ {/* Status Extended — context below the resource */} + {showOverviewStatusExtended && ( + +

+ {overviewStatusExtended} +

+
)} )} @@ -813,8 +808,9 @@ export function ResourceDetailDrawerContent({
Finding Overview + Remediation - Other Findings For This Resource + Findings for this resource Scans Events @@ -828,132 +824,26 @@ export function ResourceDetailDrawerContent({ > {showOverviewCheckMetaContent ? ( <> - {/* Card 1: Risk + Description + Status Extended */} - {(checkMeta.risk || - checkMeta.description || - showOverviewFindingContent) && ( - - {checkMeta.risk && ( - - - Risk: - - {checkMeta.risk} - - )} - {checkMeta.description && ( -
- - Description: - - - {checkMeta.description} - -
- )} - {showOverviewFindingContent && - showOverviewStatusExtended && ( -
- - Status Extended: - -

- {overviewStatusExtended} -

-
- )} -
+ {/* Risk */} + {checkMeta.risk && ( +
+ + Risk: + + {checkMeta.risk} +
)} - {/* Card 2: Remediation + Commands */} - {(checkMeta.remediation.recommendation.text || - recommendationLink || - checkMeta.remediation.code.cli || - checkMeta.remediation.code.terraform || - checkMeta.remediation.code.nativeiac) && ( - - {(checkMeta.remediation.recommendation.text || - recommendationLink) && ( -
- - Remediation: - -
- {checkMeta.remediation.recommendation.text && ( -
- - {checkMeta.remediation.recommendation.text} - -
- )} - {recommendationLink && ( - - {recommendationLink.label} - - )} -
-
- )} - - {checkMeta.remediation.code.cli && ( -
- {renderRemediationCodeBlock({ - label: "CLI Command", - language: QUERY_EDITOR_LANGUAGE.SHELL, - value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, - copyValue: stripCodeFences( - checkMeta.remediation.code.cli, - ), - showLineNumbers: false, - })} -
- )} - - {checkMeta.remediation.code.terraform && ( -
- {renderRemediationCodeBlock({ - label: "Terraform", - language: QUERY_EDITOR_LANGUAGE.HCL, - value: stripCodeFences( - checkMeta.remediation.code.terraform, - ), - })} -
- )} - - {checkMeta.remediation.code.nativeiac && providerType && ( -
- {renderRemediationCodeBlock({ - label: nativeIacConfig.label, - language: nativeIacConfig.language, - value: stripCodeFences( - checkMeta.remediation.code.nativeiac, - ), - })} -
- )} - - {checkMeta.remediation.code.other && ( -
- - Remediation Steps: - - - {checkMeta.remediation.code.other} - -
- )} -
+ {/* Description */} + {checkMeta.description && ( +
+ + Description: + + + {checkMeta.description} + +
)} {checkMeta.additionalUrls.length > 0 && ( @@ -999,13 +889,154 @@ export function ResourceDetailDrawerContent({
)} + + {/* IDs */} + +
+ + + + + {currentResource?.findingId || f?.id ? ( + + ) : ( + + )} + + + {f?.uid ? ( + + ) : ( + + )} + +
+
) : ( )} - {/* Other Findings For This Resource */} + {/* Remediation */} + + {showOverviewCheckMetaContent ? ( + checkMeta.remediation.recommendation.text || + recommendationLink || + checkMeta.remediation.code.cli || + checkMeta.remediation.code.terraform || + checkMeta.remediation.code.nativeiac || + checkMeta.remediation.code.other ? ( + <> + {(checkMeta.remediation.recommendation.text || + recommendationLink) && ( +
+ + Remediation: + +
+ {checkMeta.remediation.recommendation.text && ( +
+ + {checkMeta.remediation.recommendation.text} + +
+ )} + {recommendationLink && ( + + {recommendationLink.label} + + )} +
+
+ )} + + {(checkMeta.remediation.code.cli || + checkMeta.remediation.code.terraform || + checkMeta.remediation.code.nativeiac || + checkMeta.remediation.code.other) && ( + + {checkMeta.remediation.code.cli && ( +
+ {renderRemediationCodeBlock({ + label: "CLI Command", + language: QUERY_EDITOR_LANGUAGE.SHELL, + value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, + copyValue: stripCodeFences( + checkMeta.remediation.code.cli, + ), + showLineNumbers: false, + })} +
+ )} + + {checkMeta.remediation.code.terraform && ( +
+ {renderRemediationCodeBlock({ + label: "Terraform", + language: QUERY_EDITOR_LANGUAGE.HCL, + value: stripCodeFences( + checkMeta.remediation.code.terraform, + ), + })} +
+ )} + + {checkMeta.remediation.code.nativeiac && providerType && ( +
+ {renderRemediationCodeBlock({ + label: nativeIacConfig.label, + language: nativeIacConfig.language, + value: stripCodeFences( + checkMeta.remediation.code.nativeiac, + ), + })} +
+ )} + + {checkMeta.remediation.code.other && ( +
+ + Remediation Steps: + + + {checkMeta.remediation.code.other} + +
+ )} +
+ )} + + ) : ( +

+ No remediation guidance available for this check. +

+ ) + ) : ( + + )} +
+ + {/* Findings for this resource */} -
+
@@ -1148,8 +1179,6 @@ export function ResourceDetailDrawerContent({ dateTime={f?.scan?.completedAt || "-"} /> -
-
@@ -1288,8 +1317,9 @@ function ScansNavigationSkeleton() { labels={["Scan Name", "Resources Scanned", "Progress"]} /> - - +
); } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.test.tsx index 8ba70ebf09..4866b7cfb4 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.test.tsx @@ -10,17 +10,12 @@ vi.mock("@/components/shadcn/skeleton/skeleton", () => ({ import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; describe("ResourceDetailSkeleton", () => { - it("should include placeholders for group and resource type fields", () => { + it("should render placeholders mirroring the resource info grid layout", () => { render(); + // Account/Resource entity placeholders + 5 info fields (dates + service + + // region) + actions button = at least 7 blocks rendered. const blocks = screen.getAllByTestId("skeleton-block"); - const classes = blocks.map( - (block) => block.getAttribute("data-class") ?? "", - ); - - expect(classes).toContain("h-3.5 w-10 rounded"); - expect(classes).toContain("h-5 w-18 rounded"); - expect(classes).toContain("h-3.5 w-20 rounded"); - expect(classes).toContain("h-5 w-28 rounded"); + expect(blocks.length).toBeGreaterThanOrEqual(7); }); }); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx index 9ef08ee14e..f126925162 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx @@ -8,26 +8,21 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; export function ResourceDetailSkeleton() { return (
-
- {/* Row 1: Account, Resource, Service, Region */} - - - - +
+ {/* Row 1: Account, Resource */} +
+ + +
- {/* Row 2: Last detected, First seen, Failing for, Group */} - - - - - - {/* Row 3: Check ID, Finding ID, Finding UID */} - - - - - {/* Row 4: Resource type */} - + {/* Row 2: Last detected, First seen, Failing for, Service, Region */} +
+ + + + + +
{/* Actions button */} @@ -36,16 +31,25 @@ export function ResourceDetailSkeleton() { ); } -function EntityInfoSkeleton({ hasIcon = false }: { hasIcon?: boolean }) { +function EntityInfoSkeleton({ + hasIcon = false, + labelWidth, +}: { + hasIcon?: boolean; + labelWidth?: string; +}) { return ( -
- {hasIcon && } -
-
- - +
+ {labelWidth && } +
+ {hasIcon && } +
+
+ + +
+
-
); diff --git a/ui/components/shadcn/card/card.tsx b/ui/components/shadcn/card/card.tsx index f5fd5656c6..629d0de9f6 100644 --- a/ui/components/shadcn/card/card.tsx +++ b/ui/components/shadcn/card/card.tsx @@ -20,6 +20,8 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", { inner: "rounded-[12px] backdrop-blur-[46px] border-border-neutral-tertiary bg-bg-neutral-tertiary", danger: "border-border-error bg-bg-fail-secondary gap-1 rounded-[12px]", + success: "border-bg-pass bg-bg-pass-secondary gap-1 rounded-[12px]", + warning: "border-bg-warning bg-bg-warning-secondary gap-1 rounded-[12px]", }, padding: { default: "", @@ -40,6 +42,16 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", { padding: "default", className: "px-4 py-3", // md padding by default for danger }, + { + variant: "success", + padding: "default", + className: "px-4 py-3", // md padding by default for success + }, + { + variant: "warning", + padding: "default", + className: "px-4 py-3", // md padding by default for warning + }, ], defaultVariants: { variant: "default", diff --git a/ui/components/ui/entities/entity-info.tsx b/ui/components/ui/entities/entity-info.tsx index e9a6627f31..0cf4fe68f0 100644 --- a/ui/components/ui/entities/entity-info.tsx +++ b/ui/components/ui/entities/entity-info.tsx @@ -23,6 +23,8 @@ interface EntityInfoProps { /** Label before the ID value. Defaults to "UID" */ idLabel?: string; showCopyAction?: boolean; + /** Inline element rendered after the entity ID (e.g. action link). */ + idAction?: ReactNode; /** @deprecated No longer used — layout handles overflow naturally */ maxWidth?: string; /** @deprecated No longer used */ @@ -40,6 +42,7 @@ export const EntityInfo = ({ badge, idLabel = "UID", showCopyAction = true, + idAction, }: EntityInfoProps) => { const canCopy = Boolean(entityId && showCopyAction); const renderedIcon = @@ -73,7 +76,7 @@ export const EntityInfo = ({ )}
{entityId && ( -
+
{idLabel}: @@ -82,6 +85,7 @@ export const EntityInfo = ({ className="max-w-[160px]" hideCopyButton={!canCopy} /> + {idAction && {idAction}}
)}
diff --git a/ui/lib/finding-detail.ts b/ui/lib/finding-detail.ts index 69c0224297..d1b506424a 100644 --- a/ui/lib/finding-detail.ts +++ b/ui/lib/finding-detail.ts @@ -53,6 +53,7 @@ export function findingToFindingResourceRow( region: resource?.region || "-", severity: finding.attributes.severity, status: finding.attributes.status, + statusExtended: finding.attributes.status_extended, delta: finding.attributes.delta, isMuted: finding.attributes.muted, mutedReason: finding.attributes.muted_reason, diff --git a/ui/types/findings-table.ts b/ui/types/findings-table.ts index 30adbd216b..fe65e2a1a1 100644 --- a/ui/types/findings-table.ts +++ b/ui/types/findings-table.ts @@ -60,6 +60,7 @@ export interface FindingResourceRow { region: string; severity: Severity; status: string; + statusExtended?: string; delta?: string | null; isMuted: boolean; mutedReason?: string;