mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
1667 lines
60 KiB
TypeScript
1667 lines
60 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
CircleArrowRight,
|
|
CircleChevronLeft,
|
|
CircleChevronRight,
|
|
Container,
|
|
ExternalLink,
|
|
VolumeOff,
|
|
VolumeX,
|
|
} from "lucide-react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useState } from "react";
|
|
|
|
import { getCompliancesOverview } from "@/actions/compliances";
|
|
import {
|
|
loadLatestFindingTriageNote,
|
|
type ResourceDrawerFinding,
|
|
updateFindingTriage,
|
|
} from "@/actions/findings";
|
|
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
|
|
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
|
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
|
import { getComplianceIcon } from "@/components/icons";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
InfoField,
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "@/components/shadcn";
|
|
import { Card } from "@/components/shadcn/card/card";
|
|
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
|
|
import {
|
|
ActionDropdown,
|
|
ActionDropdownItem,
|
|
} from "@/components/shadcn/dropdown";
|
|
import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
|
|
import { EntityInfo } from "@/components/shadcn/entities/entity-info";
|
|
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
|
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/shadcn/table";
|
|
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
|
|
import {
|
|
type FindingStatus,
|
|
StatusFindingBadge,
|
|
} from "@/components/shadcn/table/status-finding-badge";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/shadcn/tooltip";
|
|
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
|
|
import { resolveExternalTarget } from "@/components/shared/external-resource-link";
|
|
import {
|
|
QUERY_EDITOR_LANGUAGE,
|
|
QueryCodeEditor,
|
|
type QueryEditorLanguage,
|
|
} from "@/components/shared/query-code-editor";
|
|
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
|
|
import { getFailingForLabel, formatDuration } from "@/lib/date-utils";
|
|
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
|
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
|
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
|
import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts";
|
|
import { getRegionFlag } from "@/lib/region-flags";
|
|
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
|
import type { ComplianceOverviewData } from "@/types/compliance";
|
|
import type { FindingResourceRow } from "@/types/findings-table";
|
|
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
|
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
|
|
|
import { Muted } from "../../muted";
|
|
import { DeltaIndicator } from "../delta-indicator";
|
|
import {
|
|
FindingNoteActionItem,
|
|
FindingTriageStatusBadge,
|
|
FindingTriageStatusCell,
|
|
} from "../finding-triage-cells";
|
|
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
|
|
|
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
|
import type { CheckMeta } from "./use-resource-detail-drawer";
|
|
|
|
const OTHER_FINDINGS_ACTION_CELL_CLASS =
|
|
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
|
|
|
|
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
|
|
function stripCodeFences(code: string): string {
|
|
return code
|
|
.replace(/^```\w*\n?/, "")
|
|
.replace(/\n?```\s*$/, "")
|
|
.trim();
|
|
}
|
|
|
|
function isNonEmptyString(value: string | null | undefined): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function resolveNativeIacConfig(providerType: string | undefined): {
|
|
label: string;
|
|
language: QueryEditorLanguage;
|
|
} {
|
|
switch (providerType) {
|
|
case "aws":
|
|
return {
|
|
label: "CloudFormation",
|
|
language: QUERY_EDITOR_LANGUAGE.YAML,
|
|
};
|
|
case "azure":
|
|
return {
|
|
label: "Bicep",
|
|
language: QUERY_EDITOR_LANGUAGE.BICEP,
|
|
};
|
|
case "kubernetes":
|
|
return {
|
|
label: "Kubernetes Manifest",
|
|
language: QUERY_EDITOR_LANGUAGE.YAML,
|
|
};
|
|
default:
|
|
return {
|
|
label: "Native IaC",
|
|
language: QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
|
|
};
|
|
}
|
|
}
|
|
|
|
function renderRemediationCodeBlock({
|
|
label,
|
|
value,
|
|
copyValue,
|
|
language = QUERY_EDITOR_LANGUAGE.PLAIN_TEXT,
|
|
showLineNumbers = true,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
copyValue?: string;
|
|
language?: QueryEditorLanguage;
|
|
showLineNumbers?: boolean;
|
|
}) {
|
|
return (
|
|
<QueryCodeEditor
|
|
ariaLabel={label}
|
|
language={language}
|
|
value={value}
|
|
copyValue={copyValue}
|
|
editable={false}
|
|
minHeight={96}
|
|
showCopyButton
|
|
showLineNumbers={showLineNumbers}
|
|
onChange={() => {}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function normalizeComplianceFrameworkName(framework: string): string {
|
|
return framework
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_]+/g, "-")
|
|
.replace(/-+/g, "-");
|
|
}
|
|
|
|
function stripComplianceVersionSuffix(framework: string): string {
|
|
return framework.replace(/-\d+(?:\.\d+)*$/g, "");
|
|
}
|
|
|
|
function canonicalComplianceKey(framework: string): string {
|
|
return stripComplianceVersionSuffix(
|
|
normalizeComplianceFrameworkName(framework),
|
|
)
|
|
.replace(/[^a-z0-9]+/g, "")
|
|
.trim();
|
|
}
|
|
|
|
function complianceTokens(framework: string): string[] {
|
|
return stripComplianceVersionSuffix(
|
|
normalizeComplianceFrameworkName(framework),
|
|
)
|
|
.split("-")
|
|
.map((token) => token.trim())
|
|
.filter(Boolean)
|
|
.filter((token) => !/^\d+(?:\.\d+)*$/.test(token));
|
|
}
|
|
|
|
function complianceMatchScore(
|
|
sourceFramework: string,
|
|
targetFramework: string,
|
|
): number {
|
|
const normalizedSource = normalizeComplianceFrameworkName(sourceFramework);
|
|
const normalizedTarget = normalizeComplianceFrameworkName(targetFramework);
|
|
|
|
if (normalizedSource === normalizedTarget) {
|
|
return 5;
|
|
}
|
|
|
|
const canonicalSource = canonicalComplianceKey(sourceFramework);
|
|
const canonicalTarget = canonicalComplianceKey(targetFramework);
|
|
|
|
if (canonicalSource === canonicalTarget) {
|
|
return 4;
|
|
}
|
|
|
|
if (canonicalSource && canonicalTarget) {
|
|
const sourceTokens = canonicalSource.split("-");
|
|
const targetTokens = canonicalTarget.split("-");
|
|
if (
|
|
sourceTokens.length !== targetTokens.length &&
|
|
(sourceTokens.every((t) => targetTokens.includes(t)) ||
|
|
targetTokens.every((t) => sourceTokens.includes(t)))
|
|
) {
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
const sourceTokens = complianceTokens(sourceFramework);
|
|
const targetTokens = complianceTokens(targetFramework);
|
|
if (!sourceTokens.length || !targetTokens.length) {
|
|
return 0;
|
|
}
|
|
|
|
const sourceMatchesTarget = sourceTokens.every((token) =>
|
|
targetTokens.includes(token),
|
|
);
|
|
const targetMatchesSource = targetTokens.every((token) =>
|
|
sourceTokens.includes(token),
|
|
);
|
|
|
|
if (sourceMatchesTarget || targetMatchesSource) {
|
|
return 2;
|
|
}
|
|
|
|
if (
|
|
sourceTokens.some((token) => targetTokens.includes(token)) &&
|
|
canonicalSource &&
|
|
canonicalTarget &&
|
|
(canonicalTarget.includes(canonicalSource) ||
|
|
canonicalSource.includes(canonicalTarget))
|
|
) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
|
if (!scanFilterValue) {
|
|
return [];
|
|
}
|
|
|
|
return scanFilterValue
|
|
.split(",")
|
|
.map((scanId) => scanId.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function resolveComplianceMatch(
|
|
compliances: ComplianceOverviewData[] | undefined,
|
|
framework: string,
|
|
): {
|
|
complianceId: string;
|
|
framework: string;
|
|
version: string;
|
|
} | null {
|
|
if (!compliances?.length) {
|
|
return null;
|
|
}
|
|
|
|
const match = compliances
|
|
.map((compliance) => ({
|
|
compliance,
|
|
score: complianceMatchScore(framework, compliance.attributes.framework),
|
|
}))
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => b.score - a.score)[0]?.compliance;
|
|
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
complianceId: match.id,
|
|
framework: match.attributes.framework,
|
|
version: match.attributes.version,
|
|
};
|
|
}
|
|
|
|
function buildComplianceDetailHref({
|
|
complianceId,
|
|
framework,
|
|
version,
|
|
scanId,
|
|
regionFilter,
|
|
}: {
|
|
complianceId: string;
|
|
framework: string;
|
|
version: string;
|
|
scanId: string;
|
|
regionFilter: string | null;
|
|
}): string {
|
|
const params = new URLSearchParams();
|
|
params.set("complianceId", complianceId);
|
|
if (version) {
|
|
params.set("version", version);
|
|
}
|
|
params.set("scanId", scanId);
|
|
|
|
if (regionFilter) {
|
|
params.set("filter[region__in]", regionFilter);
|
|
}
|
|
|
|
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
|
|
}
|
|
|
|
function buildResourceDetailHref(resourceId: string): string {
|
|
const params = new URLSearchParams();
|
|
params.set("resourceId", resourceId);
|
|
return `/resources?${params.toString()}`;
|
|
}
|
|
|
|
interface ResourceDetailDrawerContentProps {
|
|
isLoading: boolean;
|
|
isNavigating: boolean;
|
|
checkMeta: CheckMeta | null;
|
|
currentIndex: number;
|
|
totalResources: number;
|
|
currentResource?: FindingResourceRow | null;
|
|
currentFinding: ResourceDrawerFinding | null;
|
|
otherFindings: ResourceDrawerFinding[];
|
|
showSyntheticResourceHint?: boolean;
|
|
onNavigatePrev: () => void;
|
|
onNavigateNext: () => void;
|
|
onMuteComplete: () => void;
|
|
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
|
|
}
|
|
|
|
export function ResourceDetailDrawerContent({
|
|
isLoading,
|
|
isNavigating,
|
|
checkMeta,
|
|
currentIndex,
|
|
totalResources,
|
|
currentResource = null,
|
|
currentFinding,
|
|
otherFindings,
|
|
showSyntheticResourceHint = false,
|
|
onNavigatePrev,
|
|
onNavigateNext,
|
|
onMuteComplete,
|
|
onTriageUpdate,
|
|
}: ResourceDetailDrawerContentProps) {
|
|
const searchParams = useSearchParams();
|
|
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
|
const [resolvingFramework, setResolvingFramework] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
|
Set<string>
|
|
>(new Set());
|
|
|
|
// Initial load — no check metadata yet
|
|
if (!checkMeta && isLoading) {
|
|
return (
|
|
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
|
{/* Header skeleton */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="h-6 w-14 rounded-md" />
|
|
<Skeleton className="h-6 w-16 rounded-md" />
|
|
</div>
|
|
<Skeleton className="h-6 w-3/4 rounded" />
|
|
</div>
|
|
{/* Navigation skeleton */}
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-7 w-48 rounded" />
|
|
<div className="flex gap-1">
|
|
<Skeleton className="size-8 rounded-md" />
|
|
<Skeleton className="size-8 rounded-md" />
|
|
</div>
|
|
</div>
|
|
{/* Resource card skeleton */}
|
|
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 rounded-lg border p-4">
|
|
<ResourceDetailSkeleton />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!checkMeta) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
// checkMeta is always available from here.
|
|
// During carousel navigation we only trust row-backed data until the next
|
|
// finding payload is fully ready, otherwise stale details flash briefly.
|
|
const f = isNavigating ? null : currentFinding;
|
|
const jiraPayload = createJiraDispatchPayload({
|
|
targetIds: f ? [f.id] : [],
|
|
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
|
findingTitle: checkMeta.checkTitle,
|
|
});
|
|
const isCheckMetaFresh =
|
|
!currentResource?.checkId || currentResource.checkId === checkMeta.checkId;
|
|
const showCheckMetaContent = !isNavigating || isCheckMetaFresh;
|
|
const findingStatus = f?.status ?? currentResource?.status;
|
|
const findingSeverity = f?.severity ?? currentResource?.severity;
|
|
const findingDelta = f?.delta ?? currentResource?.delta;
|
|
const findingIsMuted = f?.isMuted ?? currentResource?.isMuted;
|
|
const findingMutedReason =
|
|
f?.mutedReason ?? currentResource?.mutedReason ?? "This finding is muted";
|
|
const providerType = currentResource?.providerType ?? f?.providerType;
|
|
const providerAlias = currentResource?.providerAlias ?? f?.providerAlias;
|
|
const providerUid = currentResource?.providerUid ?? f?.providerUid;
|
|
const resourceName = currentResource?.resourceName ?? f?.resourceName;
|
|
const resourceUid = currentResource?.resourceUid ?? f?.resourceUid;
|
|
const resourceService = currentResource?.service ?? f?.resourceService;
|
|
const resourceRegion = currentResource?.region ?? f?.resourceRegion;
|
|
const findingTriage = f?.triage ?? currentResource?.triage;
|
|
const resourceRegionLabel = resourceRegion || "-";
|
|
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
|
|
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
|
|
const hasPrev = currentIndex > 0;
|
|
const hasNext = currentIndex < totalResources - 1;
|
|
const selectedScanIds = parseSelectedScanIds(
|
|
searchParams.get("filter[scan__in]"),
|
|
);
|
|
const complianceScanId =
|
|
selectedScanIds.length === 1
|
|
? selectedScanIds[0]
|
|
: selectedScanIds.length === 0
|
|
? (f?.scan?.id ?? null)
|
|
: null;
|
|
const regionFilter = searchParams.get("filter[region__in]");
|
|
const nativeIacConfig = resolveNativeIacConfig(providerType);
|
|
const showOverviewCheckMetaContent = showCheckMetaContent;
|
|
const resourceDetailHref = f?.resourceId
|
|
? buildResourceDetailHref(f.resourceId)
|
|
: null;
|
|
const externalResourceTarget = resolveExternalTarget({
|
|
providerType,
|
|
resourceUid,
|
|
providerUid,
|
|
resourceName,
|
|
findingUid: f?.uid,
|
|
region: resourceRegion,
|
|
});
|
|
const findingRecommendationUrl = f?.remediation.recommendation.url;
|
|
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
|
|
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
|
|
? findingRecommendationUrl
|
|
: isNonEmptyString(checkRecommendationUrl)
|
|
? checkRecommendationUrl
|
|
: null;
|
|
const recommendationLink = recommendationUrl
|
|
? {
|
|
href: recommendationUrl,
|
|
label: getRecommendationLinkLabel(recommendationUrl),
|
|
}
|
|
: null;
|
|
const overviewStatusExtended =
|
|
currentResource?.statusExtended || f?.statusExtended;
|
|
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
|
const findingAnalysisPrompt = buildFindingAnalysisPrompt({
|
|
findingId: currentResource?.findingId ?? f?.id,
|
|
providerUid,
|
|
resourceUid,
|
|
checkId: currentResource?.checkId ?? checkMeta.checkId,
|
|
severity: findingSeverity,
|
|
status: findingStatus,
|
|
detail: overviewStatusExtended,
|
|
risk: f?.risk || checkMeta.risk,
|
|
});
|
|
|
|
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
|
await updateFindingTriage(input);
|
|
if (shouldRefreshAfterTriageUpdate(input)) {
|
|
onMuteComplete();
|
|
return;
|
|
}
|
|
|
|
onTriageUpdate?.(input);
|
|
};
|
|
|
|
const handleOpenCompliance = async (framework: string) => {
|
|
if (!complianceScanId || resolvingFramework) {
|
|
return;
|
|
}
|
|
|
|
setResolvingFramework(framework);
|
|
|
|
try {
|
|
const compliancesOverview = await getCompliancesOverview({
|
|
scanId: complianceScanId,
|
|
});
|
|
const complianceMatch = resolveComplianceMatch(
|
|
compliancesOverview?.data,
|
|
framework,
|
|
);
|
|
|
|
if (!complianceMatch) {
|
|
return;
|
|
}
|
|
|
|
window.open(
|
|
buildComplianceDetailHref({
|
|
complianceId: complianceMatch.complianceId,
|
|
framework: complianceMatch.framework,
|
|
version: complianceMatch.version,
|
|
scanId: complianceScanId,
|
|
regionFilter,
|
|
}),
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
} catch (error) {
|
|
console.error("Error resolving compliance detail:", error);
|
|
} finally {
|
|
setResolvingFramework(null);
|
|
}
|
|
};
|
|
|
|
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 && !f.isMuted && (
|
|
<MuteFindingsModal
|
|
isOpen={isMuteModalOpen}
|
|
onOpenChange={setIsMuteModalOpen}
|
|
findingIds={[f.id]}
|
|
onComplete={() => {
|
|
setIsMuteModalOpen(false);
|
|
onMuteComplete();
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Header: keep row-backed badges visible; only hide stale check metadata */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{findingStatus && (
|
|
<StatusFindingBadge status={findingStatus as FindingStatus} />
|
|
)}
|
|
{findingSeverity && <SeverityBadge severity={findingSeverity} />}
|
|
{findingDelta && (
|
|
<div className="flex items-center gap-1 capitalize">
|
|
<DeltaIndicator delta={findingDelta} />
|
|
<span className="text-text-neutral-secondary text-xs">
|
|
{findingDelta}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{findingIsMuted !== undefined && (
|
|
<Muted isMuted={findingIsMuted} mutedReason={findingMutedReason} />
|
|
)}
|
|
{findingTriage && <FindingTriageStatusBadge triage={findingTriage} />}
|
|
</div>
|
|
|
|
{showCheckMetaContent ? (
|
|
<>
|
|
<h2 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
|
|
{checkMeta.checkTitle}
|
|
</h2>
|
|
|
|
{checkMeta.complianceFrameworks.length > 0 && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-text-neutral-tertiary text-xs font-medium">
|
|
Compliance Frameworks:
|
|
</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{checkMeta.complianceFrameworks.map((framework) => {
|
|
const icon = getComplianceIcon(framework);
|
|
const isNavigable = Boolean(complianceScanId);
|
|
const isResolving = resolvingFramework === framework;
|
|
|
|
return icon ? (
|
|
<Tooltip key={framework}>
|
|
<TooltipTrigger asChild>
|
|
{isNavigable ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Open ${framework} compliance details`}
|
|
onClick={() =>
|
|
void handleOpenCompliance(framework)
|
|
}
|
|
disabled={Boolean(resolvingFramework)}
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5 transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
|
>
|
|
<Image
|
|
src={icon}
|
|
alt={framework}
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
{isResolving && (
|
|
<span className="sr-only">
|
|
Opening compliance
|
|
</span>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
|
<Image
|
|
src={icon}
|
|
alt={framework}
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{framework}</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip key={framework}>
|
|
<TooltipTrigger asChild>
|
|
{isNavigable ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Open ${framework} compliance details`}
|
|
onClick={() =>
|
|
void handleOpenCompliance(framework)
|
|
}
|
|
disabled={Boolean(resolvingFramework)}
|
|
className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
|
>
|
|
{framework}
|
|
{isResolving && (
|
|
<span className="sr-only">
|
|
Opening compliance
|
|
</span>
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
|
{framework}
|
|
</span>
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>{framework}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div
|
|
data-testid="drawer-header-skeleton"
|
|
aria-hidden="true"
|
|
className="flex flex-col gap-2"
|
|
>
|
|
<Skeleton className="h-6 w-3/4 rounded" />
|
|
<div className="flex flex-col gap-1.5">
|
|
<Skeleton className="h-4 w-28 rounded" />
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Skeleton className="h-7 w-16 rounded-md" />
|
|
<Skeleton className="h-7 w-20 rounded-md" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation: "Resource (X of N)" */}
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="tag" className="rounded text-sm">
|
|
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-0">
|
|
<button
|
|
type="button"
|
|
disabled={!hasPrev}
|
|
onClick={onNavigatePrev}
|
|
className="text-text-neutral-secondary 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="text-text-neutral-secondary 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 minimal-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto rounded-lg border p-4">
|
|
{/* Resource info — shows loading when currentFinding is not yet available */}
|
|
{!currentResource && !f ? (
|
|
<ResourceDetailSkeleton />
|
|
) : (
|
|
<>
|
|
<div className="flex items-start gap-4">
|
|
{/* Resource info grid — 4 data columns */}
|
|
<div
|
|
data-responsive-container
|
|
className="@container flex min-w-0 flex-1 flex-col gap-4"
|
|
>
|
|
{/* Row 1: Provider, Resource, Service, Region */}
|
|
<div
|
|
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8"
|
|
data-testid="resource-detail-primary-metadata-row"
|
|
>
|
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
|
|
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
|
Provider
|
|
</span>
|
|
<EntityInfo
|
|
cloudProvider={providerType}
|
|
nameIcon={<Box className="size-4" />}
|
|
entityAlias={providerAlias}
|
|
entityId={providerUid}
|
|
/>
|
|
</div>
|
|
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
|
|
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
|
|
Resource
|
|
</span>
|
|
<EntityInfo
|
|
nameIcon={<Container className="size-4" />}
|
|
entityAlias={resourceName}
|
|
entityId={resourceUid}
|
|
idLabel="UID"
|
|
nameAction={
|
|
resourceDetailHref ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="link" size="link-sm" asChild>
|
|
<Link
|
|
href={resourceDetailHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
prefetch={false}
|
|
>
|
|
<span className="sr-only">View Resource</span>
|
|
<ExternalLink
|
|
className="size-3"
|
|
aria-hidden="true"
|
|
/>
|
|
</Link>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
View Resource
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
<InfoField
|
|
label="Service"
|
|
variant="compact"
|
|
className="min-w-0"
|
|
>
|
|
<span className="block truncate whitespace-nowrap">
|
|
{resourceService}
|
|
</span>
|
|
</InfoField>
|
|
<InfoField
|
|
label="Region"
|
|
variant="compact"
|
|
className="min-w-0"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
|
|
{getRegionFlag(resourceRegionLabel) && (
|
|
<span className="shrink-0 translate-y-px text-base leading-none">
|
|
{getRegionFlag(resourceRegionLabel)}
|
|
</span>
|
|
)}
|
|
<span className="truncate">{resourceRegionLabel}</span>
|
|
</span>
|
|
</InfoField>
|
|
</div>
|
|
|
|
{/* Row 2: Last detected, First seen, Failing for */}
|
|
<div
|
|
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8"
|
|
data-testid="resource-detail-secondary-metadata-row"
|
|
>
|
|
<InfoField
|
|
label="Last detected"
|
|
variant="compact"
|
|
className="min-w-0"
|
|
>
|
|
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
|
</InfoField>
|
|
<InfoField
|
|
label="First seen"
|
|
variant="compact"
|
|
className="min-w-0"
|
|
>
|
|
<DateWithTime inline dateTime={firstSeenAt || "-"} />
|
|
</InfoField>
|
|
<InfoField
|
|
label="Failing for"
|
|
variant="compact"
|
|
className="min-w-0"
|
|
>
|
|
<span className="block truncate whitespace-nowrap">
|
|
{getFailingForLabel(firstSeenAt) || "-"}
|
|
</span>
|
|
</InfoField>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions button — fixed size, aligned with row 1 */}
|
|
<div className="shrink-0">
|
|
{f ? (
|
|
<ActionDropdown
|
|
variant="bordered"
|
|
ariaLabel="Resource actions"
|
|
>
|
|
{findingTriage && (
|
|
<FindingNoteActionItem
|
|
triage={findingTriage}
|
|
findingContext={{
|
|
title: checkMeta.checkTitle,
|
|
resource: resourceName,
|
|
provider: providerAlias,
|
|
providerType,
|
|
}}
|
|
onTriageUpdateAction={handleDrawerTriageUpdate}
|
|
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
|
/>
|
|
)}
|
|
<ActionDropdownItem
|
|
icon={
|
|
f.isMuted ? (
|
|
<VolumeOff className="size-5" />
|
|
) : (
|
|
<VolumeX className="size-5" />
|
|
)
|
|
}
|
|
label={f.isMuted ? "Muted" : "Mute"}
|
|
disabled={f.isMuted}
|
|
onSelect={() => setIsMuteModalOpen(true)}
|
|
/>
|
|
<JiraDispatchActionItem
|
|
label={buildJiraActionLabel({ findingCount: 1 })}
|
|
payload={jiraPayload}
|
|
/>
|
|
{externalResourceTarget && (
|
|
<ActionDropdownItem
|
|
icon={<ExternalLink className="size-5" />}
|
|
label={externalResourceTarget.label}
|
|
onSelect={() =>
|
|
window.open(
|
|
externalResourceTarget.url,
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</ActionDropdown>
|
|
) : (
|
|
<Skeleton className="size-8 rounded-md" />
|
|
)}
|
|
</div>
|
|
</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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<Tabs
|
|
defaultValue="overview"
|
|
className="mt-2 flex min-h-fit w-full flex-1 flex-col md:min-h-0"
|
|
>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<TabsList>
|
|
<TabsTrigger value="overview" tooltip="Overview">
|
|
Overview
|
|
</TabsTrigger>
|
|
<TabsTrigger value="remediation" tooltip="Remediation">
|
|
Remediation
|
|
</TabsTrigger>
|
|
<TabsTrigger value="metadata" tooltip="Resource Metadata">
|
|
Evidence
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="other-findings"
|
|
tooltip="Other Findings for this resource"
|
|
>
|
|
Other findings
|
|
</TabsTrigger>
|
|
<TabsTrigger value="scans" tooltip="Scans">
|
|
Scans
|
|
</TabsTrigger>
|
|
<TabsTrigger value="events" tooltip="Events">
|
|
Events
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Overview — check-level data from checkMeta (always stable) */}
|
|
<TabsContent
|
|
value="overview"
|
|
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
|
|
>
|
|
{showOverviewCheckMetaContent ? (
|
|
<>
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<Card variant="inner">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-text-neutral-secondary text-xs">
|
|
References:
|
|
</span>
|
|
<div className="flex flex-col items-start gap-1">
|
|
{checkMeta.additionalUrls.map((link) => (
|
|
<Button
|
|
key={link}
|
|
variant="link"
|
|
size="link-xs"
|
|
className="h-auto justify-start p-0 text-left break-all whitespace-normal!"
|
|
asChild
|
|
>
|
|
<Link
|
|
href={link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
prefetch={false}
|
|
>
|
|
{link}
|
|
</Link>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{checkMeta.categories.length > 0 && (
|
|
<Card variant="inner">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-text-neutral-secondary text-xs">
|
|
Categories:
|
|
</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{checkMeta.categories.map((category) => (
|
|
<Badge
|
|
key={category}
|
|
variant="outline"
|
|
className="text-xs capitalize"
|
|
>
|
|
{category}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</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>
|
|
|
|
{/* 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">
|
|
<div
|
|
className="flex min-w-0 items-center justify-between gap-3"
|
|
data-testid="remediation-heading-row"
|
|
>
|
|
<span className="text-text-neutral-primary text-sm font-semibold">
|
|
Remediation:
|
|
</span>
|
|
{recommendationLink && (
|
|
<Button
|
|
variant="link"
|
|
size="link-xs"
|
|
className="shrink-0 whitespace-nowrap"
|
|
asChild
|
|
>
|
|
<Link
|
|
href={recommendationLink.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
prefetch={false}
|
|
>
|
|
{recommendationLink.label}
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div>
|
|
{checkMeta.remediation.recommendation.text && (
|
|
<div className="text-text-neutral-primary text-sm">
|
|
<MarkdownContainer>
|
|
{checkMeta.remediation.recommendation.text}
|
|
</MarkdownContainer>
|
|
</div>
|
|
)}
|
|
</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>
|
|
|
|
{/* Metadata */}
|
|
<TabsContent
|
|
value="metadata"
|
|
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
|
|
>
|
|
{isNavigating ? (
|
|
<MetadataNavigationSkeleton />
|
|
) : (
|
|
<ResourceMetadataPanel
|
|
metadata={f?.resourceMetadata}
|
|
details={f?.resourceDetails}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Other findings — findings affecting this same resource */}
|
|
<TabsContent
|
|
value="other-findings"
|
|
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
|
|
>
|
|
{!f && !isNavigating ? (
|
|
<LoadingState spinnerClassName="size-5" />
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-end">
|
|
{isNavigating ? (
|
|
<Skeleton
|
|
className="h-4 w-24 rounded"
|
|
data-testid="other-findings-total-entries-skeleton"
|
|
/>
|
|
) : (
|
|
<span className="text-text-neutral-tertiary text-sm">
|
|
{otherFindings.length} Total Entries
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-14" />
|
|
<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>
|
|
<span className="text-text-neutral-secondary text-sm font-medium">
|
|
Triage
|
|
</span>
|
|
</TableHead>
|
|
<TableHead className="w-10" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isNavigating ? (
|
|
<OtherFindingsNavigationSkeletonRows />
|
|
) : otherFindings.length > 0 ? (
|
|
otherFindings.map((finding) => (
|
|
<OtherFindingRow
|
|
key={finding.id}
|
|
finding={finding}
|
|
isOptimisticallyMuted={optimisticallyMutedIds.has(
|
|
finding.id,
|
|
)}
|
|
onMuted={() =>
|
|
setOptimisticallyMutedIds((prev) =>
|
|
new Set(prev).add(finding.id),
|
|
)
|
|
}
|
|
onTriageUpdateAction={handleDrawerTriageUpdate}
|
|
/>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-16 text-center">
|
|
<span className="text-text-neutral-tertiary text-sm">
|
|
{showSyntheticResourceHint
|
|
? "No other findings are available for this IaC resource."
|
|
: "No other findings for this resource."}
|
|
</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Scans Tab */}
|
|
<TabsContent value="scans" className="flex flex-col gap-4">
|
|
{!f && !isNavigating ? (
|
|
<p className="text-text-neutral-tertiary text-sm">
|
|
Scan information is not available.
|
|
</p>
|
|
) : isNavigating ? (
|
|
<ScansNavigationSkeleton />
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-text-neutral-secondary text-xs">
|
|
Showing the latest scan that evaluated this finding
|
|
</p>
|
|
<Button variant="link" size="link-sm" asChild>
|
|
<Link
|
|
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
View scan
|
|
<ExternalLink className="size-3" />
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<InfoField label="Scan Name" variant="compact">
|
|
{f?.scan?.name || "N/A"}
|
|
</InfoField>
|
|
<InfoField label="Resources Scanned" variant="compact">
|
|
{f?.scan?.uniqueResourceCount}
|
|
</InfoField>
|
|
<InfoField label="Progress" variant="compact">
|
|
{f?.scan?.progress}%
|
|
</InfoField>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<InfoField label="Trigger" variant="compact">
|
|
{f?.scan?.trigger}
|
|
</InfoField>
|
|
<InfoField label="State" variant="compact">
|
|
{f?.scan?.state}
|
|
</InfoField>
|
|
<InfoField label="Duration" variant="compact">
|
|
{f?.scan?.duration !== undefined
|
|
? formatDuration(f.scan.duration)
|
|
: "-"}
|
|
</InfoField>
|
|
</div>
|
|
<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>
|
|
<InfoField label="Completed At" variant="compact">
|
|
<DateWithTime
|
|
inline
|
|
dateTime={f?.scan?.completedAt || "-"}
|
|
/>
|
|
</InfoField>
|
|
<InfoField label="Launched At" variant="compact">
|
|
<DateWithTime
|
|
inline
|
|
dateTime={f?.scan?.insertedAt || "-"}
|
|
/>
|
|
</InfoField>
|
|
{f?.scan?.scheduledAt && (
|
|
<InfoField label="Scheduled At" variant="compact">
|
|
<DateWithTime inline dateTime={f.scan.scheduledAt} />
|
|
</InfoField>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Events Tab */}
|
|
<TabsContent
|
|
value="events"
|
|
className="flex min-h-0 flex-1 flex-col gap-4"
|
|
>
|
|
{isNavigating ? (
|
|
<EventsNavigationSkeleton />
|
|
) : (
|
|
<>
|
|
<EventsTimeline
|
|
resourceId={f?.resourceId}
|
|
isAwsProvider={f?.providerType === "aws"}
|
|
/>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* Lighthouse AI button */}
|
|
{!isNavigating && (
|
|
<a
|
|
href={`/lighthouse?${new URLSearchParams({ prompt: findingAnalysisPrompt }).toString()}`}
|
|
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
|
|
style={{
|
|
background: "var(--gradient-lighthouse)",
|
|
}}
|
|
>
|
|
<CircleArrowRight className="size-5" />
|
|
Analyze This Finding With Lighthouse AI
|
|
</a>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OverviewNavigationSkeleton({ testId }: { testId?: string } = {}) {
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-4"
|
|
data-testid={testId ?? "overview-navigation-skeleton"}
|
|
>
|
|
<Card variant="inner">
|
|
<OverviewCardSkeleton lineWidths={["w-24", "w-full", "w-5/6"]} />
|
|
</Card>
|
|
<Card variant="inner">
|
|
<OverviewCardSkeleton
|
|
lineWidths={["w-28", "w-3/4", "w-full", "w-2/3"]}
|
|
/>
|
|
</Card>
|
|
<Card variant="inner">
|
|
<OverviewCardSkeleton lineWidths={["w-20", "w-40", "w-24"]} />
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OverviewCardSkeleton({ lineWidths }: { lineWidths: string[] }) {
|
|
return (
|
|
<div className="flex flex-col gap-3" aria-hidden="true">
|
|
{lineWidths.map((lineWidth, index) => (
|
|
<Skeleton
|
|
key={`${lineWidth}-${index}`}
|
|
className={`h-4 ${lineWidth} rounded`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OtherFindingsNavigationSkeletonRows() {
|
|
return (
|
|
<>
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<TableRow
|
|
key={index}
|
|
data-testid={
|
|
index === 0 ? "other-findings-navigation-skeleton" : undefined
|
|
}
|
|
aria-hidden="true"
|
|
>
|
|
<TableCell className="w-10">
|
|
<Skeleton className="h-5 w-5 rounded-full" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Skeleton className="h-5 w-16 rounded" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Skeleton className="h-5 w-3/4 rounded" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Skeleton className="h-5 w-14 rounded" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Skeleton className="h-5 w-20 rounded" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
|
</TableCell>
|
|
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
|
<Skeleton className="h-5 w-5 rounded" />
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ScansNavigationSkeleton() {
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-4"
|
|
data-testid="scans-navigation-skeleton"
|
|
aria-hidden="true"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-text-neutral-secondary text-xs">
|
|
Showing the latest scan that evaluated this finding
|
|
</p>
|
|
<Skeleton className="h-4 w-16 rounded" />
|
|
</div>
|
|
<ScansInfoGridSkeleton
|
|
labels={["Scan Name", "Resources Scanned", "Progress"]}
|
|
/>
|
|
<ScansInfoGridSkeleton labels={["Trigger", "State", "Duration"]} />
|
|
<ScansInfoGridSkeleton
|
|
labels={["Started At", "Completed At", "Launched At"]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScansInfoGridSkeleton({ labels }: { labels: string[] }) {
|
|
const columnCount = labels.length;
|
|
|
|
return (
|
|
<div
|
|
className={`grid grid-cols-1 gap-4 ${columnCount === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}
|
|
>
|
|
{labels.map((label, index) => (
|
|
<div key={index} className="flex flex-col gap-1">
|
|
<span className="text-text-neutral-secondary text-xs">{label}</span>
|
|
<Skeleton className="h-5 w-28 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventsNavigationSkeleton() {
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-4"
|
|
data-testid="events-navigation-skeleton"
|
|
aria-hidden="true"
|
|
>
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-start gap-3 rounded-lg border p-4"
|
|
>
|
|
<Skeleton className="mt-0.5 size-3 rounded-full" />
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-1/3 rounded" />
|
|
<Skeleton className="h-4 w-5/6 rounded" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetadataNavigationSkeleton() {
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-4"
|
|
data-testid="metadata-navigation-skeleton"
|
|
aria-hidden="true"
|
|
>
|
|
<Card variant="inner">
|
|
<OverviewCardSkeleton lineWidths={["w-20", "w-full", "w-3/4"]} />
|
|
</Card>
|
|
<Skeleton className="h-56 w-full rounded" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OtherFindingRow({
|
|
finding,
|
|
isOptimisticallyMuted,
|
|
onMuted,
|
|
onTriageUpdateAction,
|
|
}: {
|
|
finding: ResourceDrawerFinding;
|
|
isOptimisticallyMuted: boolean;
|
|
onMuted: () => void;
|
|
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
|
|
}) {
|
|
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
|
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
|
const jiraPayload = createJiraDispatchPayload({
|
|
targetIds: [finding.id],
|
|
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
|
findingTitle: finding.checkTitle,
|
|
});
|
|
|
|
const findingUrl = `/findings?filter%5Bcheck_id__in%5D=${encodeURIComponent(finding.checkId)}&filter%5Bmuted%5D=include`;
|
|
|
|
return (
|
|
<>
|
|
{!isMuted && (
|
|
<MuteFindingsModal
|
|
isOpen={isMuteModalOpen}
|
|
onOpenChange={setIsMuteModalOpen}
|
|
findingIds={[finding.id]}
|
|
onComplete={() => {
|
|
setIsMuteModalOpen(false);
|
|
onMuted();
|
|
}}
|
|
/>
|
|
)}
|
|
<TableRow
|
|
className="group cursor-pointer"
|
|
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
|
>
|
|
<TableCell className="w-14">
|
|
<div className="flex items-center justify-center">
|
|
<NotificationIndicator
|
|
isMuted={isMuted}
|
|
delta={
|
|
finding.delta === DeltaValues.NEW ||
|
|
finding.delta === DeltaValues.CHANGED
|
|
? finding.delta
|
|
: undefined
|
|
}
|
|
mutedReason={finding.mutedReason ?? undefined}
|
|
showDeltaWhenMuted
|
|
reserveMutedSlot
|
|
/>
|
|
</div>
|
|
</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>
|
|
<FindingTriageStatusCell
|
|
triage={finding.triage}
|
|
onTriageUpdateAction={onTriageUpdateAction}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ActionDropdown ariaLabel="Finding actions">
|
|
{finding.triage && (
|
|
<FindingNoteActionItem
|
|
triage={finding.triage}
|
|
findingContext={{
|
|
title: finding.checkTitle,
|
|
resource: finding.resourceName,
|
|
provider: finding.providerAlias,
|
|
providerType: finding.providerType,
|
|
}}
|
|
onTriageUpdateAction={onTriageUpdateAction}
|
|
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
|
/>
|
|
)}
|
|
<ActionDropdownItem
|
|
icon={
|
|
isMuted ? (
|
|
<VolumeOff className="size-5" />
|
|
) : (
|
|
<VolumeX className="size-5" />
|
|
)
|
|
}
|
|
label={isMuted ? "Muted" : "Mute"}
|
|
disabled={isMuted}
|
|
onSelect={() => setIsMuteModalOpen(true)}
|
|
/>
|
|
<JiraDispatchActionItem
|
|
label={buildJiraActionLabel({ findingCount: 1 })}
|
|
payload={jiraPayload}
|
|
/>
|
|
</ActionDropdown>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
</>
|
|
);
|
|
}
|