diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e4859d1834..1aa6b1d11e 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to the **Prowler UI** are documented in this file. - `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213) - "Resource Metadata / Evidence" tab in the finding detail drawer [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187) +### 🐞 Fixed + +- Resource detail panels: metadata editor now scrolls internally with the minimal scrollbar across the finding drawer and `/resources/:id`, tab labels truncate with tooltips on narrow widths, and "View in AWS Console" moved from the resource UID row to the resource actions menu [(#11325)](https://github.com/prowler-cloud/prowler/pull/11325) + --- ## [1.27.0] (Prowler v5.27.0) 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 e9cae8740a..368a6b7dc3 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 @@ -91,12 +91,14 @@ vi.mock("@/components/shadcn", () => { InfoField: ({ children, label, + className, }: { children: ReactNode; label: string; variant?: string; + className?: string; }) => ( -
+
{label} {children}
@@ -280,12 +282,6 @@ vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ ), })); -vi.mock("@/components/ui/custom/custom-link", () => ({ - CustomLink: ({ children, href }: { children: ReactNode; href: string }) => ( - {children} - ), -})); - vi.mock("@/components/ui/entities/date-with-time", () => ({ DateWithTime: ({ dateTime }: { dateTime: string }) => {dateTime}, })); @@ -784,12 +780,19 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => { ); expect(screen.getByText(statusExtendedWithFixVersions)).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: "View in Prowler Hub" }), - ).toHaveAttribute( + const hubLink = screen.getByRole("link", { name: "View in Prowler Hub" }); + expect(hubLink).toHaveAttribute( "href", "https://hub.prowler.com/check/image_vulnerability", ); + expect(hubLink).toHaveAttribute("target", "_blank"); + expect(hubLink).toHaveAttribute("rel", "noopener noreferrer"); + const headingRow = screen.getByTestId("remediation-heading-row"); + expect(within(headingRow).getByText("Remediation:")).toBeInTheDocument(); + expect(hubLink).toHaveClass("shrink-0", "whitespace-nowrap"); + expect( + within(headingRow).queryByText("Open the check in Hub"), + ).not.toBeInTheDocument(); }); it("should render the official CVE reference", () => { @@ -836,10 +839,12 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => { "href", externalCveUrl, ); - expect(screen.getByRole("link", { name: externalCveUrl })).toHaveAttribute( - "href", - externalCveUrl, - ); + const referenceLink = screen.getByRole("link", { name: externalCveUrl }); + expect(referenceLink).toHaveAttribute("href", externalCveUrl); + expect(referenceLink).toHaveAttribute("target", "_blank"); + expect(referenceLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(referenceLink).toHaveClass("break-all", "text-left"); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); }); it("should render View Advisory when the recommendation URL points to GitHub Security Advisories", () => { @@ -1345,6 +1350,64 @@ describe("ResourceDetailDrawerContent — synthetic resource empty state", () => }); describe("ResourceDetailDrawerContent — current resource row display", () => { + it("should place service and region in the primary metadata row after provider and resource", () => { + // Given/When + render( + , + ); + + // Then + const primaryMetadataRow = screen.getByTestId( + "resource-detail-primary-metadata-row", + ); + expect(primaryMetadataRow).toHaveClass("grid-cols-2"); + expect(primaryMetadataRow).toHaveClass( + "@md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)]", + ); + expect( + within(primaryMetadataRow).getByText("Provider"), + ).toBeInTheDocument(); + expect( + within(primaryMetadataRow).getByText("Resource"), + ).toBeInTheDocument(); + expect(within(primaryMetadataRow).getByText("Service")).toBeInTheDocument(); + expect(within(primaryMetadataRow).getByText("Region")).toBeInTheDocument(); + expect(within(primaryMetadataRow).getByText("s3")).toHaveClass( + "truncate", + "whitespace-nowrap", + ); + expect(within(primaryMetadataRow).getByText("us-east-1")).toHaveClass( + "truncate", + ); + + const secondaryMetadataRow = screen.getByTestId( + "resource-detail-secondary-metadata-row", + ); + expect(secondaryMetadataRow).toHaveClass("grid-cols-2"); + expect(secondaryMetadataRow).toHaveClass("@md:grid-cols-3"); + expect( + within(secondaryMetadataRow).queryByText("Service"), + ).not.toBeInTheDocument(); + expect( + within(secondaryMetadataRow).queryByText("Region"), + ).not.toBeInTheDocument(); + expect(within(secondaryMetadataRow).getByText("2 days")).toHaveClass( + "truncate", + "whitespace-nowrap", + ); + }); + it("should render resource card fields from the current resource row instead of the fetched finding", () => { // Given const currentResource: FindingResourceRow = { @@ -1481,10 +1544,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () expect(screen.getByText("ec2")).toBeInTheDocument(); expect(screen.getByText("eu-west-1")).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Finding Overview" }), + screen.getByRole("button", { name: "Overview" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Findings for this resource" }), + screen.getByRole("button", { name: "Other findings" }), ).toBeInTheDocument(); expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); expect(screen.queryByText("Status extended")).not.toBeInTheDocument(); @@ -1594,10 +1657,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", () expect(screen.queryByText("Description:")).not.toBeInTheDocument(); expect(screen.queryByText("Remediation:")).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Finding Overview" }), + screen.getByRole("button", { name: "Overview" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Findings for this resource" }), + screen.getByRole("button", { name: "Other findings" }), ).toBeInTheDocument(); }); @@ -1796,7 +1859,7 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => { // Then expect( - screen.getByRole("button", { name: "Resource Metadata / Evidence" }), + screen.getByRole("button", { name: "Evidence" }), ).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 646110a752..096b4f030c 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 @@ -44,10 +44,7 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; -import { - ExternalResourceLink, - resolveExternalTarget, -} from "@/components/shared/external-resource-link"; +import { resolveExternalTarget } from "@/components/shared/external-resource-link"; import { QUERY_EDITOR_LANGUAGE, QueryCodeEditor, @@ -55,7 +52,6 @@ import { } from "@/components/shared/query-code-editor"; import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { CustomLink } from "@/components/ui/custom/custom-link"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { EntityInfo } from "@/components/ui/entities/entity-info"; import { @@ -442,7 +438,6 @@ export function ResourceDetailDrawerContent({ findingUid: f?.uid, region: resourceRegion, }); - const hasIdAction = Boolean(externalResourceTarget); const findingRecommendationUrl = f?.remediation.recommendation.url; const checkRecommendationUrl = checkMeta.remediation.recommendation.url; const recommendationUrl = isNonEmptyString(findingRecommendationUrl) @@ -690,9 +685,12 @@ export function ResourceDetailDrawerContent({
{/* Resource info grid — 4 data columns */}
- {/* Row 1: Provider (cols 1-2), Resource (cols 3-5) */} -
-
+ {/* Row 1: Provider, Resource, Service, Region */} +
+
Provider @@ -703,7 +701,7 @@ export function ResourceDetailDrawerContent({ entityId={providerUid} />
-
+
Resource @@ -737,44 +735,59 @@ export function ResourceDetailDrawerContent({ ) : undefined } - idAction={ - hasIdAction ? ( - - ) : undefined - } />
-
- - {/* Row 2: Last detected, First seen, Failing for, Service, Region */} -
- - + + + {resourceService} + - - - - - {getFailingForLabel(firstSeenAt) || "-"} - - - {resourceService} - - - + + {getRegionFlag(resourceRegionLabel) && ( - + {getRegionFlag(resourceRegionLabel)} )} - {resourceRegionLabel} + {resourceRegionLabel} + + +
+ + {/* Row 2: Last detected, First seen, Failing for */} +
+ + + + + + + + + {getFailingForLabel(firstSeenAt) || "-"}
@@ -804,6 +817,19 @@ export function ResourceDetailDrawerContent({ label="Send to Jira" onSelect={() => setIsJiraModalOpen(true)} /> + {externalResourceTarget && ( + } + label={externalResourceTarget.label} + onSelect={() => + window.open( + externalResourceTarget.url, + "_blank", + "noopener,noreferrer", + ) + } + /> + )} ) : ( @@ -844,20 +870,31 @@ export function ResourceDetailDrawerContent({ >
- Finding Overview - Remediation - - Resource Metadata / Evidence + + Overview - - Findings for this resource + + Remediation + + + Evidence + + + Other findings + + + Scans + + + Events - Scans - Events
- {/* Finding Overview — check-level data from checkMeta (always stable) */} + {/* Overview — check-level data from checkMeta (always stable) */} References: -
    - {checkMeta.additionalUrls.map((link, idx) => ( -
  • - + {checkMeta.additionalUrls.map((link) => ( +
  • + + ))} -
+
)} @@ -986,26 +1030,39 @@ export function ResourceDetailDrawerContent({ {(checkMeta.remediation.recommendation.text || recommendationLink) && (
- - Remediation: - -
+
+ + Remediation: + + {recommendationLink && ( + + )} +
+
{checkMeta.remediation.recommendation.text && ( -
+
{checkMeta.remediation.recommendation.text}
)} - {recommendationLink && ( - - {recommendationLink.label} - - )}
)} @@ -1091,7 +1148,7 @@ export function ResourceDetailDrawerContent({ )} - {/* Findings for this resource */} + {/* Other findings — findings affecting this same resource */}
- {/* Row 1: Provider, Resource */} -
- - + {/* Row 1: Provider, Resource, Service, Region */} +
+
+ +
+
+ +
+ +
- {/* Row 2: Last detected, First seen, Failing for, Service, Region */} -
+ {/* Row 2: Last detected, First seen, Failing for */} +
- -
diff --git a/ui/components/resources/table/resource-detail-content.test.ts b/ui/components/resources/table/resource-detail-content.test.ts index a60c3bfb9f..7d63cdec10 100644 --- a/ui/components/resources/table/resource-detail-content.test.ts +++ b/ui/components/resources/table/resource-detail-content.test.ts @@ -26,4 +26,23 @@ describe("resource detail content", () => { expect(source).not.toContain("useEffect"); expect(source).not.toContain("useEffect("); }); + + it("renders the external resource link below the resource title row", () => { + expect(source).toContain(`
+ { + expect(source).toContain( + 'className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4"', + ); + expect(source).toContain('className="col-span-2 md:col-span-1"'); + expect(source).toContain(`label="Created At" + variant="compact" + className="col-start-1 min-w-0"`); + expect(source).toContain(`label="Last Updated" + variant="compact" + className="col-start-2 min-w-0"`); + }); }); diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index 93c6c3ea6b..1d1e5c4c23 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -228,19 +228,20 @@ export const ResourceDetailContent = ({ Copy resource link to clipboard -
+
-
+
{providerOrg ? (
) : ( - +
+ +
)} -
+
} entityAlias={resourceName} @@ -299,10 +308,18 @@ export const ResourceDetailContent = ({ {renderValue(attributes.partition)} - + - +
@@ -320,9 +337,15 @@ export const ResourceDetailContent = ({ - Metadata - Tags - Events + + Evidence + + + Tags + + + Events +
@@ -372,7 +395,10 @@ export const ResourceDetailContent = ({ )} - + { + it("supports extra-small link buttons", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Open link" })).toHaveClass( + "text-xs", + ); + }); +}); diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx index ce7b6e5bd5..33ba131f27 100644 --- a/ui/components/shadcn/button/button.tsx +++ b/ui/components/shadcn/button/button.tsx @@ -37,6 +37,7 @@ const buttonVariants = cva( icon: "size-9", "icon-sm": "size-8", "icon-lg": "size-10", + "link-xs": "text-xs", "link-sm": "text-sm", }, }, diff --git a/ui/components/shadcn/tabs/tabs.tsx b/ui/components/shadcn/tabs/tabs.tsx index fc593ca88d..92ca5575c8 100644 --- a/ui/components/shadcn/tabs/tabs.tsx +++ b/ui/components/shadcn/tabs/tabs.tsx @@ -1,15 +1,20 @@ "use client"; import * as TabsPrimitive from "@radix-ui/react-tabs"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { cn } from "@/lib/utils"; /** * Trigger component style parts using semantic class names */ const TRIGGER_STYLES = { - base: "relative inline-flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4", + base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4", border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]", text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", active: @@ -46,7 +51,10 @@ function buildTriggerClassName(): string { * Build list className */ function buildListClassName(): string { - return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]"; + // `flex` + `min-w-0` lets the triggers shrink proportionally when the + // container is narrow, so each trigger truncates with ellipsis instead + // of forcing a horizontal scrollbar. + return "flex w-full min-w-0 items-center border-[#E9E9F0] dark:border-[#171D30]"; } function Tabs({ @@ -75,16 +83,40 @@ function TabsList({ ); } +interface TabsTriggerProps + extends ComponentProps { + /** + * When set, the trigger is wrapped in a shadcn Tooltip rendered below + * the bar. Useful for showing the full name when the label is truncated + * to ellipsis on narrow containers. + */ + tooltip?: ReactNode; +} + function TabsTrigger({ className, + tooltip, + children, ...props -}: ComponentProps) { - return ( +}: TabsTriggerProps) { + const trigger = ( + > + {/* Wrapper provides the block-level box needed for `truncate` to + * actually render an ellipsis. Padding and gap on the trigger stay + * constant; only this span shrinks below its content width. */} + {children} + + ); + if (!tooltip) return trigger; + return ( + + {trigger} + {tooltip} + ); } diff --git a/ui/components/shared/external-resource-link/external-resource-link.test.tsx b/ui/components/shared/external-resource-link/external-resource-link.test.tsx index c4f5629710..92c7e299c6 100644 --- a/ui/components/shared/external-resource-link/external-resource-link.test.tsx +++ b/ui/components/shared/external-resource-link/external-resource-link.test.tsx @@ -18,6 +18,7 @@ describe("ExternalResourceLink", () => { expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); expect(link).toHaveTextContent("View in AWS Console"); + expect(link).toHaveClass("text-xs"); }); it("renders a repository link for IaC resources", () => { diff --git a/ui/components/shared/external-resource-link/external-resource-link.tsx b/ui/components/shared/external-resource-link/external-resource-link.tsx index e08544ea19..52d5ce0c59 100644 --- a/ui/components/shared/external-resource-link/external-resource-link.tsx +++ b/ui/components/shared/external-resource-link/external-resource-link.tsx @@ -73,7 +73,7 @@ export const ExternalResourceLink = (props: ExternalResourceLinkProps) => {