-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat: new finding detail view #9727
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
alejandrobailo
merged 6 commits into
feat/PROWLER-379-new-table
from
feat/PROWLER-606-finding-detail
Jan 12, 2026
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5f5f046
feat: dependency updates
alejandrobailo b15d603
feat: add shadcn drawer and info-field components
alejandrobailo 77ef2a6
feat: export drawer and info-field from shadcn
alejandrobailo 6ed06e7
feat: implement finding detail with tabs and drawer
alejandrobailo 461ae91
styles: drawer breakpoint
alejandrobailo d23d7f9
chore: pr comments addressed
alejandrobailo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,30 @@ | ||
| "use client"; | ||
|
|
||
| import { Snippet } from "@heroui/snippet"; | ||
| import { Tooltip } from "@heroui/tooltip"; | ||
| import { ExternalLink, Link } from "lucide-react"; | ||
| import { ExternalLink, Link, X } from "lucide-react"; | ||
| import type { ReactNode } from "react"; | ||
| import ReactMarkdown from "react-markdown"; | ||
|
|
||
| import { | ||
| Card, | ||
| CardAction, | ||
| CardContent, | ||
| CardHeader, | ||
| CardTitle, | ||
| Drawer, | ||
| DrawerClose, | ||
| DrawerContent, | ||
| DrawerDescription, | ||
| DrawerHeader, | ||
| DrawerTitle, | ||
| DrawerTrigger, | ||
| InfoField, | ||
| Tabs, | ||
| TabsContent, | ||
| TabsList, | ||
| TabsTrigger, | ||
| Tooltip, | ||
| TooltipContent, | ||
| TooltipTrigger, | ||
| } from "@/components/shadcn"; | ||
| import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; | ||
| import { CustomLink } from "@/components/ui/custom/custom-link"; | ||
| import { EntityInfo, InfoField } from "@/components/ui/entities"; | ||
| import { EntityInfo } from "@/components/ui/entities"; | ||
| import { DateWithTime } from "@/components/ui/entities/date-with-time"; | ||
| import { SeverityBadge } from "@/components/ui/table/severity-badge"; | ||
| import { | ||
|
|
@@ -54,11 +64,21 @@ const formatDuration = (seconds: number) => { | |
| return parts.join(" "); | ||
| }; | ||
|
|
||
| interface FindingDetailProps { | ||
| findingDetails: FindingProps; | ||
| trigger?: ReactNode; | ||
| open?: boolean; | ||
| defaultOpen?: boolean; | ||
| onOpenChange?: (open: boolean) => void; | ||
| } | ||
|
|
||
| export const FindingDetail = ({ | ||
| findingDetails, | ||
| }: { | ||
| findingDetails: FindingProps; | ||
| }) => { | ||
| trigger, | ||
| open, | ||
| defaultOpen = false, | ||
| onOpenChange, | ||
| }: FindingDetailProps) => { | ||
| const finding = findingDetails; | ||
| const attributes = finding.attributes; | ||
| const resource = finding.relationships.resource.attributes; | ||
|
|
@@ -80,39 +100,65 @@ export const FindingDetail = ({ | |
| ) | ||
| : null; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-6 rounded-lg"> | ||
| const content = ( | ||
| <div className="flex min-w-0 flex-col gap-4 rounded-lg"> | ||
| {/* Header */} | ||
| <div className="flex items-center justify-between"> | ||
| <div> | ||
| <h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium text-gray-800"> | ||
| {renderValue(attributes.check_metadata.checktitle)} | ||
| <Tooltip content="Copy finding link to clipboard" size="sm"> | ||
| <div className="flex flex-col gap-2"> | ||
| {/* Row 1: Status badges */} | ||
| <div className="flex flex-wrap items-center gap-4"> | ||
| <StatusFindingBadge status={attributes.status as FindingStatus} /> | ||
| <SeverityBadge severity={attributes.severity || "-"} /> | ||
| {attributes.delta && ( | ||
| <div className="flex items-center gap-1 capitalize"> | ||
| <DeltaIndicator delta={attributes.delta} /> | ||
| <span className="text-text-neutral-secondary text-xs"> | ||
| {attributes.delta} | ||
| </span> | ||
| </div> | ||
| )} | ||
| <Muted | ||
| isMuted={attributes.muted} | ||
| mutedReason={attributes.muted_reason || ""} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Row 2: Title with copy link */} | ||
| <h2 className="dark:text-prowler-theme-pale/90 line-clamp-2 flex items-center gap-2 text-lg leading-tight font-medium text-gray-800"> | ||
Alan-TheGentleman marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| {renderValue(attributes.check_metadata.checktitle)} | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <button | ||
| onClick={() => navigator.clipboard.writeText(url)} | ||
| className="text-bg-data-info inline-flex cursor-pointer transition-opacity hover:opacity-80" | ||
| aria-label="Copy finding link to clipboard" | ||
| > | ||
| <Link size={16} /> | ||
| </button> | ||
| </Tooltip> | ||
| </h2> | ||
| </div> | ||
| <div className="flex items-center gap-x-4"> | ||
| <Muted | ||
| isMuted={attributes.muted} | ||
| mutedReason={attributes.muted_reason || ""} | ||
| /> | ||
| </TooltipTrigger> | ||
| <TooltipContent>Copy finding link to clipboard</TooltipContent> | ||
| </Tooltip> | ||
| </h2> | ||
|
|
||
| {/* Row 3: First Seen */} | ||
| <div className="text-text-neutral-tertiary text-sm"> | ||
| <DateWithTime inline dateTime={attributes.first_seen_at || "-"} /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Check Metadata */} | ||
| <Card variant="base" padding="lg"> | ||
| <CardHeader className="flex items-center justify-between"> | ||
| <CardTitle>Finding Details</CardTitle> | ||
| <StatusFindingBadge status={attributes.status as FindingStatus} /> | ||
| </CardHeader> | ||
| <CardContent className="flex flex-col gap-4"> | ||
| {/* Tabs */} | ||
| <Tabs defaultValue="general" className="w-full"> | ||
| <TabsList className="mb-4"> | ||
| <TabsTrigger value="general">General</TabsTrigger> | ||
| <TabsTrigger value="resources">Resources</TabsTrigger> | ||
| <TabsTrigger value="scans">Scans</TabsTrigger> | ||
| </TabsList> | ||
|
|
||
| <p className="text-text-neutral-primary mb-4 text-sm"> | ||
| Here is an overview of this finding: | ||
| </p> | ||
|
|
||
| {/* General Tab */} | ||
| <TabsContent value="general" className="flex flex-col gap-4"> | ||
| <div className="flex flex-wrap gap-4"> | ||
| <EntityInfo | ||
| cloudProvider={providerDetails.provider as ProviderType} | ||
|
|
@@ -124,37 +170,19 @@ export const FindingDetail = ({ | |
| {attributes.check_metadata.servicename} | ||
| </InfoField> | ||
| <InfoField label="Region">{resource.region}</InfoField> | ||
| <InfoField label="First Seen"> | ||
| <DateWithTime inline dateTime={attributes.first_seen_at || "-"} /> | ||
| </div> | ||
|
|
||
| <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> | ||
| <InfoField label="Check ID" variant="simple"> | ||
| <CodeSnippet value={attributes.check_id} /> | ||
| </InfoField> | ||
| {attributes.delta && ( | ||
| <InfoField | ||
| label="Delta" | ||
| tooltipContent="Indicates whether the finding is new (NEW), has changed status (CHANGED), or remains unchanged (NONE) compared to previous scans." | ||
| className="capitalize" | ||
| > | ||
| <div className="flex items-center gap-2"> | ||
| <DeltaIndicator delta={attributes.delta} /> | ||
| {attributes.delta} | ||
| </div> | ||
| </InfoField> | ||
| )} | ||
| <InfoField label="Severity" variant="simple"> | ||
| <SeverityBadge severity={attributes.severity || "-"} /> | ||
| <InfoField label="Finding ID" variant="simple"> | ||
| <CodeSnippet value={findingDetails.id} /> | ||
| </InfoField> | ||
| <InfoField label="Finding UID" variant="simple"> | ||
| <CodeSnippet value={attributes.uid} /> | ||
| </InfoField> | ||
| </div> | ||
| <InfoField label="Finding ID" variant="simple"> | ||
| <CodeSnippet value={findingDetails.id} /> | ||
| </InfoField> | ||
| <InfoField label="Check ID" variant="simple"> | ||
| <CodeSnippet value={attributes.check_id} /> | ||
| </InfoField> | ||
| <InfoField label="Finding UID" variant="simple"> | ||
| <CodeSnippet value={attributes.uid} /> | ||
| </InfoField> | ||
| <InfoField label="Resource ID" variant="simple"> | ||
| <CodeSnippet value={resource.uid} /> | ||
| </InfoField> | ||
|
|
||
| {attributes.status === "FAIL" && ( | ||
| <InfoField label="Risk" variant="simple"> | ||
|
|
@@ -261,30 +289,32 @@ export const FindingDetail = ({ | |
| <InfoField label="Categories"> | ||
| {attributes.check_metadata.categories?.join(", ") || "none"} | ||
| </InfoField> | ||
| </CardContent> | ||
| </Card> | ||
| </TabsContent> | ||
|
|
||
| {/* Resource Details */} | ||
| <Card variant="base" padding="lg"> | ||
| <CardHeader> | ||
| <CardTitle>Resource Details</CardTitle> | ||
| {/* Resources Tab */} | ||
| <TabsContent value="resources" className="flex flex-col gap-4"> | ||
| {providerDetails.provider === "iac" && gitUrl && ( | ||
| <CardAction> | ||
| <Tooltip content="Go to Resource in the Repository" size="sm"> | ||
| <a | ||
| href={gitUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-bg-data-info inline-flex cursor-pointer" | ||
| aria-label="Open resource in repository" | ||
| > | ||
| <ExternalLink size={16} className="inline" /> | ||
| </a> | ||
| <div className="flex justify-end"> | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <a | ||
| href={gitUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-bg-data-info inline-flex items-center gap-1 text-sm" | ||
| aria-label="Open resource in repository" | ||
| > | ||
| <ExternalLink size={16} /> | ||
| View in Repository | ||
| </a> | ||
| </TooltipTrigger> | ||
| <TooltipContent> | ||
| Go to Resource in the Repository | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| </CardAction> | ||
| </div> | ||
| )} | ||
| </CardHeader> | ||
| <CardContent className="flex flex-col gap-4"> | ||
|
|
||
| <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> | ||
| <InfoField label="Resource Name"> | ||
| {renderValue(resource.name)} | ||
|
|
@@ -310,6 +340,10 @@ export const FindingDetail = ({ | |
| </InfoField> | ||
| </div> | ||
|
|
||
| <InfoField label="Resource ID" variant="simple"> | ||
| <CodeSnippet value={resource.uid} /> | ||
| </InfoField> | ||
|
|
||
| {resource.tags && Object.entries(resource.tags).length > 0 && ( | ||
| <div className="flex flex-col gap-4"> | ||
| <h4 className="text-sm font-bold text-gray-500 dark:text-gray-400"> | ||
|
|
@@ -333,15 +367,10 @@ export const FindingDetail = ({ | |
| <DateWithTime inline dateTime={resource.updated_at || "-"} /> | ||
| </InfoField> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
|
|
||
| {/* Add new Scan Details section */} | ||
| <Card variant="base" padding="lg"> | ||
| <CardHeader> | ||
| <CardTitle>Scan Details</CardTitle> | ||
| </CardHeader> | ||
| <CardContent className="flex flex-col gap-4"> | ||
| </TabsContent> | ||
|
|
||
| {/* Scans Tab */} | ||
| <TabsContent value="scans" className="flex flex-col gap-4"> | ||
| <div className="grid grid-cols-1 gap-4 md:grid-cols-3"> | ||
| <InfoField label="Scan Name">{scan.name || "N/A"}</InfoField> | ||
| <InfoField label="Resources Scanned"> | ||
|
|
@@ -377,8 +406,36 @@ export const FindingDetail = ({ | |
| </InfoField> | ||
| )} | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| </TabsContent> | ||
| </Tabs> | ||
| </div> | ||
| ); | ||
|
|
||
| // If no trigger, render content directly (inline mode) | ||
| if (!trigger) { | ||
| return content; | ||
| } | ||
|
|
||
| // With trigger, wrap in Drawer | ||
| return ( | ||
| <Drawer | ||
| direction="right" | ||
| open={open} | ||
| defaultOpen={defaultOpen} | ||
| onOpenChange={onOpenChange} | ||
| > | ||
| <DrawerTrigger asChild>{trigger}</DrawerTrigger> | ||
| <DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| <DrawerHeader className="sr-only"> | ||
| <DrawerTitle>Finding Details</DrawerTitle> | ||
| <DrawerDescription>View the finding details</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> | ||
| {content} | ||
| </DrawerContent> | ||
| </Drawer> | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: Line 87-90 uses
window.locationdirectly which can cause hydration mismatch in Next.js. Consider usingusePathname()anduseSearchParams()from Next.js:This is pre-existing code, but worth addressing.