mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
refactor(ui): reorganize finding detail drawer (#11091)
This commit is contained in:
committed by
GitHub
parent
73c0305dc4
commit
1b6a459df4
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-27
@@ -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 ? <span data-testid="entity-id-action">{idAction}</span> : 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(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -448,9 +449,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
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(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -938,16 +932,16 @@ describe("ResourceDetailDrawerContent — Fix 5 & 6: Risk section styling", () =
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
|
||||
+255
-225
@@ -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({
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Resource info grid — 4 data columns */}
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfo
|
||||
cloudProvider={providerType}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={providerAlias}
|
||||
entityId={providerUid}
|
||||
/>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
entityId={resourceUid}
|
||||
idLabel="UID"
|
||||
/>
|
||||
<InfoField label="Service" variant="compact">
|
||||
{resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" variant="compact">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getRegionFlag(resourceRegionLabel) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(resourceRegionLabel)}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegionLabel}
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
{/* Row 2: Dates */}
|
||||
<InfoField label="Last detected" variant="compact">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="compact">
|
||||
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="compact">
|
||||
{getFailingForLabel(firstSeenAt) || "-"}
|
||||
</InfoField>
|
||||
<InfoField label="Group" variant="compact">
|
||||
{resourceGroup || "-"}
|
||||
</InfoField>
|
||||
|
||||
{/* Row 3: IDs */}
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={currentResource?.checkId ?? checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="compact">
|
||||
{currentResource?.findingId || f?.id ? (
|
||||
<CodeSnippet
|
||||
value={currentResource?.findingId ?? f?.id ?? "-"}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Account (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">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Account
|
||||
</span>
|
||||
<EntityInfo
|
||||
cloudProvider={providerType}
|
||||
nameIcon={<Box className="size-4" />}
|
||||
entityAlias={providerAlias}
|
||||
entityId={providerUid}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="compact">
|
||||
{f?.uid ? (
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1 @md:col-span-3">
|
||||
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
||||
Resource
|
||||
</span>
|
||||
<EntityInfo
|
||||
nameIcon={<Container className="size-4" />}
|
||||
entityAlias={resourceName}
|
||||
entityId={resourceUid}
|
||||
idLabel="UID"
|
||||
idAction={
|
||||
resourceDetailHref ? (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Resource metadata */}
|
||||
<InfoField label="Resource type" variant="compact">
|
||||
{resourceType || "-"}
|
||||
</InfoField>
|
||||
{/* 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>
|
||||
<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">
|
||||
{getRegionFlag(resourceRegionLabel) && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{getRegionFlag(resourceRegionLabel)}
|
||||
</span>
|
||||
)}
|
||||
{resourceRegionLabel}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions button — fixed size, aligned with row 1 */}
|
||||
@@ -788,19 +774,28 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceDetailHref && (
|
||||
<div className="border-border-neutral-secondary flex justify-end border-t pt-3">
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={resourceDetailHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Resource
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Status Extended — context below the resource */}
|
||||
{showOverviewStatusExtended && (
|
||||
<Card
|
||||
variant={
|
||||
findingStatus === "PASS"
|
||||
? "success"
|
||||
: findingStatus === "MANUAL"
|
||||
? "warning"
|
||||
: "danger"
|
||||
}
|
||||
className={
|
||||
findingStatus === "MUTED"
|
||||
? "border-border-neutral-tertiary bg-bg-neutral-tertiary"
|
||||
: findingStatus === "MANUAL"
|
||||
? "bg-orange-100 dark:bg-[color-mix(in_oklch,var(--bg-warning-secondary)_90%,white)]"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-text-neutral-primary text-sm leading-relaxed break-words">
|
||||
{overviewStatusExtended}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -813,8 +808,9 @@ 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="other-findings">
|
||||
Other Findings For This Resource
|
||||
Findings for this resource
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
@@ -828,132 +824,26 @@ export function ResourceDetailDrawerContent({
|
||||
>
|
||||
{showOverviewCheckMetaContent ? (
|
||||
<>
|
||||
{/* Card 1: Risk + Description + Status Extended */}
|
||||
{(checkMeta.risk ||
|
||||
checkMeta.description ||
|
||||
showOverviewFindingContent) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.risk && (
|
||||
<Card variant="danger">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</Card>
|
||||
)}
|
||||
{checkMeta.description && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
showOverviewStatusExtended &&
|
||||
"border-default-200 border-b pb-4",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.description}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{showOverviewFindingContent &&
|
||||
showOverviewStatusExtended && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-sm font-semibold">
|
||||
Status Extended:
|
||||
</span>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{overviewStatusExtended}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* Risk */}
|
||||
{checkMeta.risk && (
|
||||
<div className="border-border-neutral-primary flex flex-col gap-1 border-l-4 pl-3">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Risk:
|
||||
</span>
|
||||
<MarkdownContainer>{checkMeta.risk}</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card 2: Remediation + Commands */}
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink ||
|
||||
checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac) && (
|
||||
<Card variant="inner">
|
||||
{(checkMeta.remediation.recommendation.text ||
|
||||
recommendationLink) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation:
|
||||
</span>
|
||||
<div className="flex items-start gap-3">
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{recommendationLink && (
|
||||
<CustomLink
|
||||
href={recommendationLink.href}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
language: QUERY_EDITOR_LANGUAGE.SHELL,
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
),
|
||||
showLineNumbers: false,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
language: QUERY_EDITOR_LANGUAGE.HCL,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && providerType && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: nativeIacConfig.label,
|
||||
language: nativeIacConfig.language,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.other && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation Steps:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* Description */}
|
||||
{checkMeta.description && (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Description:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.description}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.additionalUrls.length > 0 && (
|
||||
@@ -999,13 +889,154 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* IDs */}
|
||||
<Card variant="inner">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-x-6">
|
||||
<InfoField label="Check ID" variant="compact">
|
||||
<CodeSnippet
|
||||
value={currentResource?.checkId ?? checkMeta.checkId}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="compact">
|
||||
{currentResource?.findingId || f?.id ? (
|
||||
<CodeSnippet
|
||||
value={currentResource?.findingId ?? f?.id ?? "-"}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="compact">
|
||||
{f?.uid ? (
|
||||
<CodeSnippet
|
||||
value={f.uid}
|
||||
transparent
|
||||
className="max-w-full text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<OverviewNavigationSkeleton />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Other Findings For This Resource */}
|
||||
{/* Remediation */}
|
||||
<TabsContent
|
||||
value="remediation"
|
||||
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
||||
>
|
||||
{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) && (
|
||||
<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">
|
||||
{checkMeta.remediation.recommendation.text && (
|
||||
<div className="text-text-neutral-primary flex-1 text-sm">
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
{recommendationLink && (
|
||||
<CustomLink
|
||||
href={recommendationLink.href}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{recommendationLink.label}
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(checkMeta.remediation.code.cli ||
|
||||
checkMeta.remediation.code.terraform ||
|
||||
checkMeta.remediation.code.nativeiac ||
|
||||
checkMeta.remediation.code.other) && (
|
||||
<Card variant="inner">
|
||||
{checkMeta.remediation.code.cli && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "CLI Command",
|
||||
language: QUERY_EDITOR_LANGUAGE.SHELL,
|
||||
value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`,
|
||||
copyValue: stripCodeFences(
|
||||
checkMeta.remediation.code.cli,
|
||||
),
|
||||
showLineNumbers: false,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.terraform && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: "Terraform",
|
||||
language: QUERY_EDITOR_LANGUAGE.HCL,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.terraform,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.nativeiac && providerType && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderRemediationCodeBlock({
|
||||
label: nativeIacConfig.label,
|
||||
language: nativeIacConfig.language,
|
||||
value: stripCodeFences(
|
||||
checkMeta.remediation.code.nativeiac,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checkMeta.remediation.code.other && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
Remediation Steps:
|
||||
</span>
|
||||
<MarkdownContainer>
|
||||
{checkMeta.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No remediation guidance available for this check.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<OverviewNavigationSkeleton testId="remediation-navigation-skeleton" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Findings for this resource */}
|
||||
<TabsContent
|
||||
value="other-findings"
|
||||
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
||||
@@ -1138,7 +1169,7 @@ export function ResourceDetailDrawerContent({
|
||||
: "-"}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Started At" variant="compact">
|
||||
<DateWithTime inline dateTime={f?.scan?.startedAt || "-"} />
|
||||
</InfoField>
|
||||
@@ -1148,8 +1179,6 @@ export function ResourceDetailDrawerContent({
|
||||
dateTime={f?.scan?.completedAt || "-"}
|
||||
/>
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoField label="Launched At" variant="compact">
|
||||
<DateWithTime
|
||||
inline
|
||||
@@ -1202,11 +1231,11 @@ export function ResourceDetailDrawerContent({
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewNavigationSkeleton() {
|
||||
function OverviewNavigationSkeleton({ testId }: { testId?: string } = {}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
data-testid="overview-navigation-skeleton"
|
||||
data-testid={testId ?? "overview-navigation-skeleton"}
|
||||
>
|
||||
<Card variant="inner">
|
||||
<OverviewCardSkeleton lineWidths={["w-24", "w-full", "w-5/6"]} />
|
||||
@@ -1288,8 +1317,9 @@ function ScansNavigationSkeleton() {
|
||||
labels={["Scan Name", "Resources Scanned", "Progress"]}
|
||||
/>
|
||||
<ScansInfoGridSkeleton labels={["Trigger", "State", "Duration"]} />
|
||||
<ScansInfoGridSkeleton labels={["Started At", "Completed At"]} />
|
||||
<ScansInfoGridSkeleton labels={["Launched At", "Scheduled At"]} />
|
||||
<ScansInfoGridSkeleton
|
||||
labels={["Started At", "Completed At", "Launched At"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-9
@@ -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(<ResourceDetailSkeleton />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,26 +8,21 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
export function ResourceDetailSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
|
||||
{/* Row 1: Account, Resource, Service, Region */}
|
||||
<EntityInfoSkeleton hasIcon />
|
||||
<EntityInfoSkeleton />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
|
||||
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
|
||||
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
|
||||
{/* Row 1: Account, 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" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Last detected, First seen, Failing for, Group */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
|
||||
<InfoFieldSkeleton labelWidth="w-10" valueWidth="w-18" />
|
||||
|
||||
{/* Row 3: Check ID, Finding ID, Finding UID */}
|
||||
<InfoFieldSkeleton labelWidth="w-14" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-36" />
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-36" />
|
||||
|
||||
{/* Row 4: Resource type */}
|
||||
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-28" />
|
||||
{/* 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">
|
||||
<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>
|
||||
|
||||
{/* 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 (
|
||||
<div className="flex items-center gap-4">
|
||||
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
<div className="flex flex-col gap-1">
|
||||
{labelWidth && <Skeleton className={`h-3 ${labelWidth} rounded`} />}
|
||||
<div className="flex items-center gap-4">
|
||||
{hasIcon && <Skeleton className="size-9 shrink-0 rounded-md" />}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ({
|
||||
)}
|
||||
</div>
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
{idLabel}:
|
||||
</span>
|
||||
@@ -82,6 +85,7 @@ export const EntityInfo = ({
|
||||
className="max-w-[160px]"
|
||||
hideCopyButton={!canCopy}
|
||||
/>
|
||||
{idAction && <span className="shrink-0">{idAction}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface FindingResourceRow {
|
||||
region: string;
|
||||
severity: Severity;
|
||||
status: string;
|
||||
statusExtended?: string;
|
||||
delta?: string | null;
|
||||
isMuted: boolean;
|
||||
mutedReason?: string;
|
||||
|
||||
Reference in New Issue
Block a user