mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): add resource detail drawer for finding group drill-down
This commit is contained in:
169
ui/actions/findings/findings-by-resource.adapter.ts
Normal file
169
ui/actions/findings/findings-by-resource.adapter.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderType, Severity } from "@/types";
|
||||
|
||||
/**
|
||||
* Flattened finding for the resource detail drawer.
|
||||
* Merges data from the finding attributes, its check_metadata,
|
||||
* the included resource, and the included scan/provider.
|
||||
*/
|
||||
export interface ResourceDrawerFinding {
|
||||
id: string;
|
||||
uid: string;
|
||||
checkId: string;
|
||||
checkTitle: string;
|
||||
status: string;
|
||||
severity: Severity;
|
||||
delta: string | null;
|
||||
isMuted: boolean;
|
||||
mutedReason: string | null;
|
||||
firstSeenAt: string | null;
|
||||
updatedAt: string | null;
|
||||
// Resource
|
||||
resourceUid: string;
|
||||
resourceName: string;
|
||||
resourceService: string;
|
||||
resourceRegion: string;
|
||||
resourceType: string;
|
||||
// Provider
|
||||
providerType: ProviderType;
|
||||
providerAlias: string;
|
||||
providerUid: string;
|
||||
// Check metadata (flattened)
|
||||
risk: string;
|
||||
description: string;
|
||||
statusExtended: string;
|
||||
complianceFrameworks: string[];
|
||||
categories: string[];
|
||||
remediation: {
|
||||
recommendation: { text: string; url: string };
|
||||
code: {
|
||||
cli: string;
|
||||
other: string;
|
||||
nativeiac: string;
|
||||
terraform: string;
|
||||
};
|
||||
};
|
||||
additionalUrls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts unique compliance framework names from available data.
|
||||
*
|
||||
* Supports two shapes:
|
||||
* 1. check_metadata.compliance — array of { Framework, Version, ... } objects
|
||||
* e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }]
|
||||
* 2. finding.compliance — dict with versioned keys (when API exposes it)
|
||||
* e.g. {"CIS-AWS-1.4": ["2.1"], "PCI-DSS-3.2": ["6.2"]}
|
||||
*/
|
||||
function extractComplianceFrameworks(
|
||||
metaCompliance: unknown,
|
||||
findingCompliance: Record<string, string[]> | null | undefined,
|
||||
): string[] {
|
||||
const frameworks = new Set<string>();
|
||||
|
||||
// Source 1: check_metadata.compliance — array of objects with Framework field
|
||||
if (Array.isArray(metaCompliance)) {
|
||||
for (const entry of metaCompliance) {
|
||||
if (entry?.Framework || entry?.framework) {
|
||||
frameworks.add(entry.Framework || entry.framework);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: finding.compliance — dict keys like "CIS-AWS-1.4"
|
||||
if (findingCompliance && typeof findingCompliance === "object") {
|
||||
for (const key of Object.keys(findingCompliance)) {
|
||||
const base = key.replace(/-\d+(\.\d+)*$/, "");
|
||||
frameworks.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(frameworks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the `/findings/latest?include=resources,scan.provider` response
|
||||
* into a flat ResourceDrawerFinding array.
|
||||
*
|
||||
* Uses createDict to build lookup maps from the JSON:API `included` array,
|
||||
* then resolves each finding's resource and provider relationships.
|
||||
*/
|
||||
export function adaptFindingsByResourceResponse(
|
||||
apiResponse: any,
|
||||
): ResourceDrawerFinding[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourcesDict = createDict("resources", apiResponse);
|
||||
const scansDict = createDict("scans", apiResponse);
|
||||
const providersDict = createDict("providers", apiResponse);
|
||||
|
||||
return apiResponse.data.map((item: any) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = attrs.check_metadata || {};
|
||||
const remediation = meta.remediation || {
|
||||
recommendation: { text: "", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
};
|
||||
|
||||
// Resolve resource from included
|
||||
const resourceRel = item.relationships?.resources?.data?.[0];
|
||||
const resource = resourceRel ? resourcesDict[resourceRel.id] : null;
|
||||
const resourceAttrs = resource?.attributes || {};
|
||||
|
||||
// Resolve provider via scan → provider (include path: scan.provider)
|
||||
const scanRel = item.relationships?.scan?.data;
|
||||
const scan = scanRel ? scansDict[scanRel.id] : null;
|
||||
const providerRelId =
|
||||
scan?.relationships?.provider?.data?.id ?? null;
|
||||
const provider = providerRelId ? providersDict[providerRelId] : null;
|
||||
const providerAttrs = provider?.attributes || {};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
uid: attrs.uid,
|
||||
checkId: attrs.check_id,
|
||||
checkTitle: meta.checktitle || attrs.check_id,
|
||||
status: attrs.status,
|
||||
severity: (attrs.severity || "informational") as Severity,
|
||||
delta: attrs.delta || null,
|
||||
isMuted: Boolean(attrs.muted),
|
||||
mutedReason: attrs.muted_reason || null,
|
||||
firstSeenAt: attrs.first_seen_at || null,
|
||||
updatedAt: attrs.updated_at || null,
|
||||
// Resource
|
||||
resourceUid: resourceAttrs.uid || "-",
|
||||
resourceName: resourceAttrs.name || "-",
|
||||
resourceService: resourceAttrs.service || "-",
|
||||
resourceRegion: resourceAttrs.region || "-",
|
||||
resourceType: resourceAttrs.type || "-",
|
||||
// Provider
|
||||
providerType: (providerAttrs.provider || "aws") as ProviderType,
|
||||
providerAlias: providerAttrs.alias || "",
|
||||
providerUid: providerAttrs.uid || "",
|
||||
// Check metadata
|
||||
risk: meta.risk || "",
|
||||
description: meta.description || "",
|
||||
statusExtended: attrs.status_extended || "",
|
||||
complianceFrameworks: extractComplianceFrameworks(
|
||||
meta.compliance ?? meta.Compliance,
|
||||
attrs.compliance,
|
||||
),
|
||||
categories: meta.categories || [],
|
||||
remediation: {
|
||||
recommendation: {
|
||||
text: remediation.recommendation?.text || "",
|
||||
url: remediation.recommendation?.url || "",
|
||||
},
|
||||
code: {
|
||||
cli: remediation.code?.cli || "",
|
||||
other: remediation.code?.other || "",
|
||||
nativeiac: remediation.code?.nativeiac || "",
|
||||
terraform: remediation.code?.terraform || "",
|
||||
},
|
||||
},
|
||||
additionalUrls: meta.additionalurls || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
35
ui/actions/findings/findings-by-resource.ts
Normal file
35
ui/actions/findings/findings-by-resource.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getLatestFindingsByResourceUid = async ({
|
||||
resourceUid,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
}: {
|
||||
resourceUid: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/findings/latest?include=resources,scan.provider`,
|
||||
);
|
||||
|
||||
url.searchParams.append("filter[resource_uid]", resourceUid);
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
try {
|
||||
const findings = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(findings);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings by resource UID:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./findings";
|
||||
export * from "./findings-by-resource";
|
||||
export * from "./findings-by-resource.adapter";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChevronLeft } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { cn, hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
@@ -30,6 +30,10 @@ import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import { ImpactedResourcesCell } from "./impacted-resources-cell";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
useResourceDetailDrawer,
|
||||
} from "./resource-detail-drawer";
|
||||
|
||||
interface FindingsGroupDrillDownProps {
|
||||
group: FindingGroupRow;
|
||||
@@ -93,6 +97,17 @@ export function FindingsGroupDrillDown({
|
||||
onSetLoading: handleSetLoading,
|
||||
});
|
||||
|
||||
// Resource detail drawer
|
||||
const drawer = useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: group.checkId,
|
||||
});
|
||||
|
||||
const handleDrawerMuteComplete = () => {
|
||||
drawer.closeDrawer();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Selection logic for resources
|
||||
const selectedFindingIds = Object.keys(rowSelection)
|
||||
.filter((key) => rowSelection[key])
|
||||
@@ -225,6 +240,8 @@ export function FindingsGroupDrillDown({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => drawer.openDrawer(row.index)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -271,6 +288,21 @@ export function FindingsGroupDrillDown({
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) drawer.closeDrawer();
|
||||
}}
|
||||
isLoading={drawer.isLoading}
|
||||
currentIndex={drawer.currentIndex}
|
||||
totalResources={drawer.totalResources}
|
||||
currentFinding={drawer.currentFinding}
|
||||
otherFindings={drawer.otherFindings}
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
export { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleArrowRight,
|
||||
CircleChevronLeft,
|
||||
CircleChevronRight,
|
||||
VolumeOff,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { getComplianceIcon } from "@/components/icons";
|
||||
import {
|
||||
Badge,
|
||||
InfoField,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { SeverityBadge } from "@/components/ui/table/severity-badge";
|
||||
import {
|
||||
type FindingStatus,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { NotificationIndicator } from "../notification-indicator";
|
||||
|
||||
interface ResourceDetailDrawerContentProps {
|
||||
isLoading: boolean;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
}
|
||||
|
||||
const MarkdownContainer = ({ children }: { children: string }) => (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
function getFailingForLabel(firstSeenAt: string | null): string | null {
|
||||
if (!firstSeenAt) return null;
|
||||
|
||||
const start = new Date(firstSeenAt);
|
||||
if (isNaN(start.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 1) return "< 1 day";
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""}`;
|
||||
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? "s" : ""}`;
|
||||
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears} year${diffYears > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawerContent({
|
||||
isLoading,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16">
|
||||
<TreeSpinner className="size-6" />
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
Loading finding details...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentFinding) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-16">
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No finding data available for this resource.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const f = currentFinding;
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < totalResources - 1;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
|
||||
{!f.isMuted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[f.id]}
|
||||
onComplete={() => {
|
||||
setIsMuteModalOpen(false);
|
||||
onMuteComplete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header: status badges + title */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusFindingBadge status={f.status as FindingStatus} />
|
||||
<SeverityBadge severity={f.severity} />
|
||||
<Muted
|
||||
isMuted={f.isMuted}
|
||||
mutedReason={f.mutedReason || "This finding is muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
||||
{f.checkTitle}
|
||||
</h2>
|
||||
|
||||
{f.complianceFrameworks.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{f.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
return icon ? (
|
||||
<Image
|
||||
key={framework}
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
key={framework}
|
||||
className="text-text-neutral-secondary text-xs"
|
||||
>
|
||||
{framework}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation: "Impacted Resource (X of N)" */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
Impacted Resource
|
||||
<span className="font-bold">{currentIndex + 1}</span>
|
||||
<span className="font-normal">of</span>
|
||||
<span className="font-bold">{totalResources}</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasPrev}
|
||||
onClick={onNavigatePrev}
|
||||
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
aria-label="Previous resource"
|
||||
>
|
||||
<CircleChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasNext}
|
||||
onClick={onNavigateNext}
|
||||
className="hover:bg-bg-neutral-tertiary disabled:text-text-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
aria-label="Next resource"
|
||||
>
|
||||
<CircleChevronRight className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource card */}
|
||||
<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">
|
||||
{/* Account, Resource, Service, Region, Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<EntityInfo
|
||||
cloudProvider={f.providerType}
|
||||
entityAlias={f.providerAlias}
|
||||
entityId={f.providerUid}
|
||||
/>
|
||||
<EntityInfo entityAlias={f.resourceName} entityId={f.resourceUid} />
|
||||
<InfoField label="Service" inline>
|
||||
{f.resourceService}
|
||||
</InfoField>
|
||||
<InfoField label="Region" inline>
|
||||
{f.resourceRegion}
|
||||
</InfoField>
|
||||
<div>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
f.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={f.isMuted ? "Muted" : "Mute"}
|
||||
disabled={f.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<InfoField label="Last detected" variant="simple">
|
||||
<DateWithTime inline dateTime={f.updatedAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="First seen" variant="simple">
|
||||
<DateWithTime inline dateTime={f.firstSeenAt || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Failing for" variant="simple">
|
||||
<span className="text-sm">
|
||||
{getFailingForLabel(f.firstSeenAt) || "-"}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* IDs row */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Check ID" variant="simple">
|
||||
<CodeSnippet value={f.checkId} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding ID" variant="simple">
|
||||
<CodeSnippet value={f.id} className="max-w-full" />
|
||||
</InfoField>
|
||||
<InfoField label="Finding UID" variant="simple">
|
||||
<CodeSnippet value={f.uid} className="max-w-full" />
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview" className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
|
||||
<TabsTrigger value="other-findings">
|
||||
Other Findings For This Resource
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Finding Overview */}
|
||||
<TabsContent value="overview" className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto">
|
||||
{f.status === "FAIL" && f.risk && (
|
||||
<InfoField label="Risk" variant="simple">
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full rounded-md border p-2",
|
||||
"border-border-error-primary bg-bg-fail-secondary",
|
||||
)}
|
||||
>
|
||||
<MarkdownContainer>{f.risk}</MarkdownContainer>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.description && (
|
||||
<InfoField label="Description">
|
||||
<MarkdownContainer>{f.description}</MarkdownContainer>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.statusExtended && (
|
||||
<InfoField label="Status Extended">{f.statusExtended}</InfoField>
|
||||
)}
|
||||
|
||||
{f.remediation.recommendation.text && (
|
||||
<InfoField label="Remediation">
|
||||
<div className="flex flex-col gap-2">
|
||||
<MarkdownContainer>
|
||||
{f.remediation.recommendation.text}
|
||||
</MarkdownContainer>
|
||||
{f.remediation.recommendation.url && (
|
||||
<CustomLink
|
||||
href={f.remediation.recommendation.url}
|
||||
size="sm"
|
||||
>
|
||||
Learn more
|
||||
</CustomLink>
|
||||
)}
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.remediation.code.cli && (
|
||||
<InfoField label="CLI Command" variant="simple">
|
||||
<div className="bg-bg-neutral-tertiary rounded-md p-2">
|
||||
<CodeSnippet
|
||||
value={f.remediation.code.cli}
|
||||
multiline
|
||||
className="max-w-full border-0 bg-transparent p-0"
|
||||
/>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.remediation.code.terraform && (
|
||||
<InfoField label="Terraform" variant="simple">
|
||||
<div className="bg-bg-neutral-tertiary rounded-md p-2">
|
||||
<CodeSnippet
|
||||
value={f.remediation.code.terraform}
|
||||
multiline
|
||||
className="max-w-full border-0 bg-transparent p-0"
|
||||
/>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.remediation.code.nativeiac && (
|
||||
<InfoField label="CloudFormation" variant="simple">
|
||||
<div className="bg-bg-neutral-tertiary rounded-md p-2">
|
||||
<CodeSnippet
|
||||
value={f.remediation.code.nativeiac}
|
||||
multiline
|
||||
className="max-w-full border-0 bg-transparent p-0"
|
||||
/>
|
||||
</div>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.remediation.code.other && (
|
||||
<InfoField label="Remediation Steps">
|
||||
<MarkdownContainer>
|
||||
{f.remediation.code.other}
|
||||
</MarkdownContainer>
|
||||
</InfoField>
|
||||
)}
|
||||
|
||||
{f.additionalUrls.length > 0 && (
|
||||
<InfoField label="References">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{f.additionalUrls.map((link, idx) => (
|
||||
<li key={idx}>
|
||||
<CustomLink
|
||||
href={link}
|
||||
size="sm"
|
||||
className="break-all whitespace-normal!"
|
||||
>
|
||||
{link}
|
||||
</CustomLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</InfoField>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Other Findings For This Resource */}
|
||||
<TabsContent value="other-findings" className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-text-neutral-primary text-sm font-medium">
|
||||
Failed Findings For This Resource
|
||||
</h4>
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{otherFindings.length} Total Entries
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Finding
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Severity
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Time
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{otherFindings.length > 0 ? (
|
||||
otherFindings.map((finding) => (
|
||||
<OtherFindingRow key={finding.id} finding={finding} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-16 text-center">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
No other findings for this resource.
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Lighthouse AI button */}
|
||||
<a
|
||||
href="/lighthouse"
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: "linear-gradient(96deg, #2EE59B 3.55%, #62DFF0 98.85%)",
|
||||
}}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
View This Finding With Lighthouse AI
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherFindingRow({ finding }: { finding: ResourceDrawerFinding }) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[finding.id]}
|
||||
/>
|
||||
<TableRow>
|
||||
<TableCell className="w-10">
|
||||
<NotificationIndicator isMuted={finding.isMuted} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusFindingBadge status={finding.status as FindingStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-text-neutral-primary max-w-[300px] truncate text-sm">
|
||||
{finding.checkTitle}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateWithTime dateTime={finding.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
finding.isMuted ? (
|
||||
<VolumeOff className="size-5" />
|
||||
) : (
|
||||
<VolumeX className="size-5" />
|
||||
)
|
||||
}
|
||||
label={finding.isMuted ? "Muted" : "Mute"}
|
||||
disabled={finding.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
|
||||
interface ResourceDetailDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
isLoading,
|
||||
currentIndex,
|
||||
totalResources,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
}: ResourceDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="3xl:w-1/3 h-full w-full overflow-hidden p-6 outline-none md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Resource Finding Details</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
View finding details for the selected resource
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{open && (
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={isLoading}
|
||||
currentIndex={currentIndex}
|
||||
totalResources={totalResources}
|
||||
currentFinding={currentFinding}
|
||||
otherFindings={otherFindings}
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
onMuteComplete={onMuteComplete}
|
||||
/>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
|
||||
interface UseResourceDetailDrawerOptions {
|
||||
resources: FindingResourceRow[];
|
||||
checkId: string;
|
||||
onRequestMoreResources?: () => void;
|
||||
}
|
||||
|
||||
interface UseResourceDetailDrawerReturn {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
currentIndex: number;
|
||||
totalResources: number;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
otherFindings: ResourceDrawerFinding[];
|
||||
allFindings: ResourceDrawerFinding[];
|
||||
openDrawer: (index: number) => void;
|
||||
closeDrawer: () => void;
|
||||
navigatePrev: () => void;
|
||||
navigateNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the resource detail drawer state, fetching, and navigation.
|
||||
*
|
||||
* Caches findings per resourceUid in a Map ref so navigating prev/next
|
||||
* doesn't re-fetch already-visited resources.
|
||||
*/
|
||||
export function useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId,
|
||||
onRequestMoreResources,
|
||||
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [findings, setFindings] = useState<ResourceDrawerFinding[]>([]);
|
||||
|
||||
const cacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(new Map());
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Check cache first
|
||||
const cached = cacheRef.current.get(resourceUid);
|
||||
if (cached) {
|
||||
setFindings(cached);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getLatestFindingsByResourceUid({ resourceUid });
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
cacheRef.current.set(resourceUid, adapted);
|
||||
setFindings(adapted);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings for resource:", error);
|
||||
setFindings([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDrawer = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
setCurrentIndex(index);
|
||||
setIsOpen(true);
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
|
||||
setCurrentIndex(index);
|
||||
setFindings([]);
|
||||
fetchFindings(resource.resourceUid);
|
||||
};
|
||||
|
||||
const navigatePrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
navigateTo(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateNext = () => {
|
||||
if (currentIndex < resources.length - 1) {
|
||||
navigateTo(currentIndex + 1);
|
||||
|
||||
// Pre-fetch more resources when nearing the end
|
||||
if (currentIndex >= resources.length - 3) {
|
||||
onRequestMoreResources?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The finding whose checkId matches the drill-down's checkId
|
||||
const currentFinding =
|
||||
findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null;
|
||||
|
||||
// All other findings for this resource
|
||||
const otherFindings = currentFinding
|
||||
? findings.filter((f) => f.id !== currentFinding.id)
|
||||
: findings;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isLoading,
|
||||
currentIndex,
|
||||
totalResources: resources.length,
|
||||
currentFinding,
|
||||
otherFindings,
|
||||
allFindings: findings,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user