refactor(ui): reorganize finding detail drawer (#11091)

This commit is contained in:
Hugo Pereira Brito
2026-05-11 09:47:43 +01:00
committed by GitHub
parent 73c0305dc4
commit 1b6a459df4
10 changed files with 327 additions and 289 deletions
+1
View File
@@ -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,
@@ -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();
});
@@ -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>
);
}
@@ -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>
);
+12
View File
@@ -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",
+5 -1
View File
@@ -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>
+1
View File
@@ -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,
+1
View File
@@ -60,6 +60,7 @@ export interface FindingResourceRow {
region: string;
severity: Severity;
status: string;
statusExtended?: string;
delta?: string | null;
isMuted: boolean;
mutedReason?: string;