mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
refactor(ui): improve resource detail and tab UX (#11325)
This commit is contained in:
@@ -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)
|
||||
|
||||
+82
-19
@@ -91,12 +91,14 @@ vi.mock("@/components/shadcn", () => {
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<div className={className}>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
@@ -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 }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
@@ -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(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
+131
-74
@@ -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({
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Resource info grid — 4 data columns */}
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Provider (cols 1-2), Resource (cols 3-5) */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
<div className="flex min-w-0 flex-col gap-1 @md:col-span-2">
|
||||
{/* Row 1: Provider, Resource, Service, Region */}
|
||||
<div
|
||||
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8"
|
||||
data-testid="resource-detail-primary-metadata-row"
|
||||
>
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Provider
|
||||
</span>
|
||||
@@ -703,7 +701,7 @@ export function ResourceDetailDrawerContent({
|
||||
entityId={providerUid}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1 @md:col-span-3">
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Resource
|
||||
</span>
|
||||
@@ -737,44 +735,59 @@ export function ResourceDetailDrawerContent({
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
idAction={
|
||||
hasIdAction ? (
|
||||
<ExternalResourceLink
|
||||
providerType={providerType}
|
||||
resourceUid={resourceUid}
|
||||
providerUid={providerUid}
|
||||
resourceName={resourceName}
|
||||
findingUid={f?.uid}
|
||||
region={resourceRegion}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
<InfoField label="Last detected" variant="compact">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
<InfoField
|
||||
label="Service"
|
||||
variant="compact"
|
||||
className="min-w-0"
|
||||
>
|
||||
<span className="block truncate whitespace-nowrap">
|
||||
{resourceService}
|
||||
</span>
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="compact">
|
||||
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Service" variant="compact">
|
||||
{resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<InfoField
|
||||
label="Region"
|
||||
variant="compact"
|
||||
className="min-w-0"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
||||
{getRegionFlag(resourceRegionLabel) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
<span className="shrink-0 translate-y-px text-base leading-none">
|
||||
{getRegionFlag(resourceRegionLabel)}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegionLabel}
|
||||
<span className="truncate">{resourceRegionLabel}</span>
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
<div
|
||||
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8"
|
||||
data-testid="resource-detail-secondary-metadata-row"
|
||||
>
|
||||
<InfoField
|
||||
label="Last detected"
|
||||
variant="compact"
|
||||
className="min-w-0"
|
||||
>
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField
|
||||
label="First seen"
|
||||
variant="compact"
|
||||
className="min-w-0"
|
||||
>
|
||||
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField
|
||||
label="Failing for"
|
||||
variant="compact"
|
||||
className="min-w-0"
|
||||
>
|
||||
<span className="block truncate whitespace-nowrap">
|
||||
{getFailingForLabel(firstSeenAt) || "-"}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
@@ -804,6 +817,19 @@ export function ResourceDetailDrawerContent({
|
||||
label="Send to Jira"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
{externalResourceTarget && (
|
||||
<ActionDropdownItem
|
||||
icon={<ExternalLink className="size-5" />}
|
||||
label={externalResourceTarget.label}
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
externalResourceTarget.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
@@ -844,20 +870,31 @@ export function ResourceDetailDrawerContent({
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
|
||||
<TabsTrigger value="remediation">Remediation</TabsTrigger>
|
||||
<TabsTrigger value="metadata">
|
||||
Resource Metadata / Evidence
|
||||
<TabsTrigger value="overview" tooltip="Overview">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="other-findings">
|
||||
Findings for this resource
|
||||
<TabsTrigger value="remediation" tooltip="Remediation">
|
||||
Remediation
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata" tooltip="Resource Metadata">
|
||||
Evidence
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="other-findings"
|
||||
tooltip="Other Findings for this resource"
|
||||
>
|
||||
Other findings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scans" tooltip="Scans">
|
||||
Scans
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" tooltip="Events">
|
||||
Events
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Finding Overview — check-level data from checkMeta (always stable) */}
|
||||
{/* Overview — check-level data from checkMeta (always stable) */}
|
||||
<TabsContent
|
||||
value="overview"
|
||||
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
||||
@@ -892,19 +929,26 @@ export function ResourceDetailDrawerContent({
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
References:
|
||||
</span>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{checkMeta.additionalUrls.map((link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{checkMeta.additionalUrls.map((link) => (
|
||||
<Button
|
||||
key={link}
|
||||
variant="link"
|
||||
size="link-xs"
|
||||
className="h-auto justify-start p-0 text-left break-all whitespace-normal!"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
prefetch={false}
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -986,26 +1030,39 @@ export function ResourceDetailDrawerContent({
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink) && (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex min-w-0 items-center justify-between gap-3"
|
||||
data-testid="remediation-heading-row"
|
||||
>
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Remediation:
|
||||
</span>
|
||||
{recommendationLink && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-xs"
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={recommendationLink.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
prefetch={false}
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<div className="text-text-neutral-primary text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{recommendationLink && (
|
||||
<CustomLink
|
||||
href={recommendationLink.href}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1091,7 +1148,7 @@ export function ResourceDetailDrawerContent({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings for this resource */}
|
||||
{/* Other findings — findings affecting this same resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
||||
|
||||
@@ -9,19 +9,23 @@ export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Provider, Resource */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @md:gap-x-8">
|
||||
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
|
||||
<EntityInfoSkeleton labelWidth="w-14" />
|
||||
{/* Row 1: Provider, Resource, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8">
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
|
||||
</div>
|
||||
<div className="col-span-2 @md:col-span-1">
|
||||
<EntityInfoSkeleton labelWidth="w-14" />
|
||||
</div>
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
|
||||
{/* Row 2: Last detected, First seen, Failing for */}
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8">
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(`</div>
|
||||
<ExternalResourceLink`);
|
||||
expect(source).toContain('className="self-start justify-start"');
|
||||
});
|
||||
|
||||
it("keeps resource date fields together on the third details row", () => {
|
||||
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"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,19 +228,20 @@ export const ResourceDetailContent = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy resource link to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
<ExternalResourceLink
|
||||
providerType={providerData.provider}
|
||||
resourceUid={attributes.uid}
|
||||
providerUid={providerData.uid}
|
||||
resourceName={attributes.name}
|
||||
region={attributes.region}
|
||||
/>
|
||||
</div>
|
||||
<ExternalResourceLink
|
||||
providerType={providerData.provider}
|
||||
resourceUid={attributes.uid}
|
||||
providerUid={providerData.uid}
|
||||
resourceName={attributes.name}
|
||||
region={attributes.region}
|
||||
className="justify-start self-start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
<div className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{providerOrg ? (
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<EntityInfo
|
||||
@@ -258,13 +259,21 @@ export const ResourceDetailContent = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EntityInfo
|
||||
cloudProvider={providerData.provider as ProviderType}
|
||||
entityAlias={providerData.alias ?? undefined}
|
||||
entityId={providerData.uid}
|
||||
/>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<EntityInfo
|
||||
cloudProvider={providerData.provider as ProviderType}
|
||||
entityAlias={providerData.alias ?? undefined}
|
||||
entityId={providerData.uid}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={providerOrg ? "self-end" : undefined}>
|
||||
<div
|
||||
className={
|
||||
providerOrg
|
||||
? "col-span-2 self-end md:col-span-1"
|
||||
: "col-span-2 md:col-span-1"
|
||||
}
|
||||
>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
@@ -299,10 +308,18 @@ export const ResourceDetailContent = ({
|
||||
{renderValue(attributes.partition)}
|
||||
</InfoField>
|
||||
|
||||
<InfoField label="Created At" variant="compact">
|
||||
<InfoField
|
||||
label="Created At"
|
||||
variant="compact"
|
||||
className="col-start-1 min-w-0"
|
||||
>
|
||||
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Last Updated" variant="compact">
|
||||
<InfoField
|
||||
label="Last Updated"
|
||||
variant="compact"
|
||||
className="col-start-2 min-w-0"
|
||||
>
|
||||
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
@@ -320,9 +337,15 @@ export const ResourceDetailContent = ({
|
||||
<InfoTooltip content="This table also includes muted findings" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||
<TabsTrigger value="tags">Tags</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
<TabsTrigger value="metadata" tooltip="Resource Metadata">
|
||||
Evidence
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" tooltip="Tags">
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" tooltip="Events">
|
||||
Events
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -372,7 +395,10 @@ export const ResourceDetailContent = ({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata" className="flex flex-col gap-4">
|
||||
<TabsContent
|
||||
value="metadata"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
|
||||
>
|
||||
<ResourceMetadataPanel
|
||||
metadata={attributes.metadata}
|
||||
details={attributes.details}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("shadcn Button", () => {
|
||||
it("supports extra-small link buttons", () => {
|
||||
render(
|
||||
<Button variant="link" size="link-xs">
|
||||
Open link
|
||||
</Button>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Open link" })).toHaveClass(
|
||||
"text-xs",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<typeof TabsPrimitive.Trigger> {
|
||||
/**
|
||||
* 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<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
}: TabsTriggerProps) {
|
||||
const trigger = (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(buildTriggerClassName(), className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{/* 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. */}
|
||||
<span className="block min-w-0 truncate">{children}</span>
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
if (!tooltip) return trigger;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ export const ExternalResourceLink = (props: ExternalResourceLinkProps) => {
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
size="link-xs"
|
||||
asChild
|
||||
className={props.className}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,11 @@ interface ResourceMetadataPanelProps {
|
||||
* neither is available. Reused by the resource detail view and the finding
|
||||
* detail drawer (compliance requirement findings view) to keep the UX
|
||||
* consistent across surfaces.
|
||||
*
|
||||
* Layout contract: the parent must be a bounded flex column (e.g.
|
||||
* `flex min-h-0 flex-1 flex-col overflow-hidden`). The embedded editor
|
||||
* fills that height and scrolls internally, so JSON-heavy resources do
|
||||
* not push the surrounding chrome (drawer, page) into a double scroll.
|
||||
*/
|
||||
export function ResourceMetadataPanel({
|
||||
metadata,
|
||||
@@ -57,7 +62,6 @@ export function ResourceMetadataPanel({
|
||||
value={formattedMetadata}
|
||||
copyValue={formattedMetadata}
|
||||
editable={false}
|
||||
minHeight={220}
|
||||
fill
|
||||
showCopyButton
|
||||
onChange={() => {}}
|
||||
|
||||
+27
-11
@@ -332,49 +332,65 @@
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* Minimal scrollbar styles */
|
||||
.minimal-scrollbar {
|
||||
/* Minimal scrollbar styles
|
||||
*
|
||||
* The descendant selectors target `.cm-scroller` so that CodeMirror
|
||||
* editors which receive `.minimal-scrollbar` on their `.cm-editor`
|
||||
* wrapper also style their inner scroller (the element that actually
|
||||
* overflows when the editor fills a bounded container).
|
||||
*/
|
||||
.minimal-scrollbar,
|
||||
.minimal-scrollbar .cm-scroller {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgb(203 213 225 / 0.5) transparent; /* thumb and track for Firefox */
|
||||
}
|
||||
|
||||
.minimal-scrollbar:hover {
|
||||
.minimal-scrollbar:hover,
|
||||
.minimal-scrollbar .cm-scroller:hover {
|
||||
scrollbar-color: rgb(148 163 184 / 0.7) transparent; /* darker thumb on hover */
|
||||
}
|
||||
|
||||
/* Webkit browsers (Chrome, Safari, Edge) */
|
||||
.minimal-scrollbar::-webkit-scrollbar {
|
||||
.minimal-scrollbar::-webkit-scrollbar,
|
||||
.minimal-scrollbar .cm-scroller::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-track {
|
||||
.minimal-scrollbar::-webkit-scrollbar-track,
|
||||
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb {
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb,
|
||||
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(203 213 225 / 0.5);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
.minimal-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(148 163 184 / 0.7);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .minimal-scrollbar {
|
||||
.dark .minimal-scrollbar,
|
||||
.dark .minimal-scrollbar .cm-scroller {
|
||||
scrollbar-color: rgb(71 85 105 / 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar:hover {
|
||||
.dark .minimal-scrollbar:hover,
|
||||
.dark .minimal-scrollbar .cm-scroller:hover {
|
||||
scrollbar-color: rgb(100 116 139 / 0.7) transparent;
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb {
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb,
|
||||
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(100 116 139 / 0.7);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user