diff --git a/.changeset/dull-taxis-walk.md b/.changeset/dull-taxis-walk.md new file mode 100644 index 0000000000..8b3f063bec --- /dev/null +++ b/.changeset/dull-taxis-walk.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": major +--- + +Adds false positive remediation on the image details page with create and revert. Introduces image version details page to show deployment locations for each image version. diff --git a/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx b/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx index fdfb985dc5..1b5fd55811 100644 --- a/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx +++ b/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx @@ -7,8 +7,9 @@ import React from "react" import { FallbackProps } from "react-error-boundary" import { Message } from "@cloudoperators/juno-ui-components" -const ErrorFallback = ({ error }: FallbackProps) => ( - -) +const ErrorFallback = ({ error }: FallbackProps) => { + const message = error instanceof Error && error.message ? error.message : "An error occurred" + return +} export default ErrorFallback diff --git a/apps/heureka/.gitignore b/apps/heureka/.gitignore index 65a986bd60..bd4e876713 100644 --- a/apps/heureka/.gitignore +++ b/apps/heureka/.gitignore @@ -33,4 +33,4 @@ npm-debug.log* .env.local .env.development.local .env.test.local -.env.production.local +.env.production.local \ No newline at end of file diff --git a/apps/heureka/codegen.ts b/apps/heureka/codegen.ts index cb3ef40dad..1db7c86a0c 100644 --- a/apps/heureka/codegen.ts +++ b/apps/heureka/codegen.ts @@ -21,6 +21,11 @@ const config: CodegenConfig = { withHooks: false, withHOC: false, withComponent: false, + // Apollo Client v4 removed several legacy exported helper types (e.g. QueryResult, MutationFunction). + // Prevent codegen from generating those helper type aliases so the generated file stays compatible. + withResultType: false, + withMutationFn: false, + withMutationOptionsType: false, }, }, }, diff --git a/apps/heureka/src/api/createRemediation.tsx b/apps/heureka/src/api/createRemediation.tsx new file mode 100644 index 0000000000..3de224fcda --- /dev/null +++ b/apps/heureka/src/api/createRemediation.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApolloClient } from "@apollo/client" +import { + CreateRemediationDocument, + CreateRemediationMutation, + CreateRemediationMutationVariables, + RemediationInput, +} from "../generated/graphql" + +type CreateRemediationParams = { + apiClient: ApolloClient + input: RemediationInput +} + +export const createRemediation = async ({ + apiClient, + input, +}: CreateRemediationParams): Promise => { + const result = await apiClient.mutate({ + mutation: CreateRemediationDocument, + variables: { input }, + }) + + if (!result.data?.createRemediation) { + throw new Error("Failed to create remediation") + } + + return result.data.createRemediation +} diff --git a/apps/heureka/src/api/deleteRemediation.tsx b/apps/heureka/src/api/deleteRemediation.tsx new file mode 100644 index 0000000000..b4735c3c94 --- /dev/null +++ b/apps/heureka/src/api/deleteRemediation.tsx @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApolloClient } from "@apollo/client" +import { + DeleteRemediationDocument, + DeleteRemediationMutation, + DeleteRemediationMutationVariables, +} from "../generated/graphql" + +type DeleteRemediationParams = { + apiClient: ApolloClient + remediationId: string +} + +export const deleteRemediation = async ({ apiClient, remediationId }: DeleteRemediationParams): Promise => { + const result = await apiClient.mutate({ + mutation: DeleteRemediationDocument, + variables: { id: remediationId }, + }) + + if (!result.data?.deleteRemediation) { + throw new Error("Failed to delete remediation") + } + + return result.data.deleteRemediation +} diff --git a/apps/heureka/src/api/fetchImageVersions.tsx b/apps/heureka/src/api/fetchImageVersions.tsx new file mode 100644 index 0000000000..fbc76ec2f5 --- /dev/null +++ b/apps/heureka/src/api/fetchImageVersions.tsx @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ObservableQuery } from "@apollo/client" +import { GetImageVersionsDocument, GetImageVersionsQuery, ImageVersionFilter } from "../generated/graphql" +import { RouteContext } from "../routes/-types" + +type FetchImageVersionsParams = Pick & { + filter: ImageVersionFilter + after?: string | null + first?: number + firstVulnerabilities?: number + afterVulnerabilities?: string | null + firstOccurences?: number + afterOccurences?: string | null +} + +export const fetchImageVersions = ({ + queryClient, + apiClient, + filter, + after, + first, + firstVulnerabilities, + afterVulnerabilities, + firstOccurences, + afterOccurences, +}: FetchImageVersionsParams): Promise> => { + const queryKey = [ + "imageVersions", + filter, + after, + first, + firstVulnerabilities, + afterVulnerabilities, + firstOccurences, + afterOccurences, + ] + + return queryClient.ensureQueryData({ + queryKey, + queryFn: () => + apiClient.query({ + query: GetImageVersionsDocument, + variables: { + filter, + first, + after, + firstVulnerabilities, + afterVulnerabilities, + firstOccurences, + afterOccurences, + }, + }), + }) +} diff --git a/apps/heureka/src/api/fetchImages.tsx b/apps/heureka/src/api/fetchImages.tsx index c8a16b026e..a4216842b8 100644 --- a/apps/heureka/src/api/fetchImages.tsx +++ b/apps/heureka/src/api/fetchImages.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ApolloQueryResult } from "@apollo/client" +import { ObservableQuery } from "@apollo/client" import { GetImagesDocument, GetImagesQuery, ImageFilter, VulnerabilityFilter } from "../generated/graphql" import { RouteContext } from "../routes/-types" @@ -29,7 +29,7 @@ export const fetchImages = ({ firstVersions, afterVersions, vulFilter, -}: FetchImagesParams): Promise> => { +}: FetchImagesParams): Promise> => { return queryClient.ensureQueryData({ queryKey: [ "images", diff --git a/apps/heureka/src/api/fetchRemediations.tsx b/apps/heureka/src/api/fetchRemediations.tsx new file mode 100644 index 0000000000..234284547a --- /dev/null +++ b/apps/heureka/src/api/fetchRemediations.tsx @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ObservableQuery } from "@apollo/client" +import { GetRemediationsDocument, GetRemediationsQuery, RemediationFilter } from "../generated/graphql" +import { RouteContext } from "../routes/-types" + +type FetchRemediationsParams = Pick & { + filter?: RemediationFilter +} + +export const fetchRemediations = ({ + queryClient, + apiClient, + filter, +}: FetchRemediationsParams): Promise> => { + const queryKey = ["remediations", filter] + + return queryClient.ensureQueryData({ + queryKey, + queryFn: () => + apiClient.query({ + query: GetRemediationsDocument, + variables: { + filter, + }, + }), + }) +} diff --git a/apps/heureka/src/api/fetchService.tsx b/apps/heureka/src/api/fetchService.tsx index 3ec3344fe4..cdbe2f47da 100644 --- a/apps/heureka/src/api/fetchService.tsx +++ b/apps/heureka/src/api/fetchService.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ApolloQueryResult } from "@apollo/client" +import { ObservableQuery } from "@apollo/client" import { GetServicesDocument, GetServicesQuery, OrderDirection, ServiceOrderByField } from "../generated/graphql" import { RouteContext } from "../routes/-types" @@ -15,7 +15,7 @@ export const fetchService = ({ queryClient, apiClient, service, -}: FetchServiceParams): Promise> => { +}: FetchServiceParams): Promise> => { return queryClient.ensureQueryData({ queryKey: ["services", service], queryFn: () => diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/FalsePositiveModal.test.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/FalsePositiveModal.test.tsx new file mode 100644 index 0000000000..3d07a00a44 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/FalsePositiveModal.test.tsx @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { FalsePositiveModal } from "./index" + +const defaultProps = { + open: true, + onClose: () => {}, + onConfirm: () => Promise.resolve(), + vulnerability: "CVE-2024-1234", + service: "my-service", + image: "my-image", +} + +const renderModal = (props = {}) => { + return render( + + + + ) +} + +describe("FalsePositiveModal", () => { + it("renders with title and vulnerability details when open", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Mark as False Positive" })).toBeInTheDocument() + expect(screen.getByText(/Vulnerability:/)).toBeInTheDocument() + expect(screen.getByText("CVE-2024-1234")).toBeInTheDocument() + expect(screen.getByText(/Service:/)).toBeInTheDocument() + expect(screen.getByText("my-service")).toBeInTheDocument() + expect(screen.getByText(/Image:/)).toBeInTheDocument() + expect(screen.getByText("my-image")).toBeInTheDocument() + }) + + it("renders Cancel and Mark as False Positive buttons", () => { + renderModal() + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Mark as False Positive" })).toBeInTheDocument() + }) + + it("calls onClose when Cancel is clicked", async () => { + const onClose = vi.fn() + const user = userEvent.setup() + renderModal({ onClose }) + await user.click(screen.getByRole("button", { name: "Cancel" })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx new file mode 100644 index 0000000000..b49a30318c --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect } from "react" +import { + Modal, + ModalFooter, + Button, + Stack, + Textarea, + DateTimePicker, + Message, +} from "@cloudoperators/juno-ui-components" +import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" + +type FalsePositiveModalProps = { + open: boolean + onClose: () => void + onConfirm: (input: RemediationInput) => Promise + vulnerability: string + severity?: string + service: string + image: string + /** Error message to show when createRemediation fails. Shown above the form content. */ + errorMessage?: string | null +} + +const CONFIRM_LABEL = "Mark as False Positive" +const CANCEL_LABEL = "Cancel" + +const toSeverityValue = (severity: string): SeverityValues | undefined => { + if (!severity) return undefined + const normalized = severity.charAt(0).toUpperCase() + severity.slice(1).toLowerCase() + const value = normalized as SeverityValues + return Object.values(SeverityValues).includes(value) ? value : undefined +} + +export const FalsePositiveModal: React.FC = ({ + open, + onClose, + onConfirm, + vulnerability, + severity, + service, + image, + errorMessage, +}) => { + const [description, setDescription] = useState("") + const [expirationDate, setExpirationDate] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [descriptionError, setDescriptionError] = useState("") + const isMountedRef = useRef(true) + + useEffect(() => { + return () => { + isMountedRef.current = false + } + }, []) + + const descriptionTrimmed = description.trim() + + const handleConfirm = async () => { + if (!descriptionTrimmed) { + setDescriptionError("Description is required") + return + } + + setDescriptionError("") + setIsSubmitting(true) + try { + const input: RemediationInput = { + type: RemediationTypeValues.FalsePositive, + vulnerability, + service, + image, + description: descriptionTrimmed, + ...(severity && { severity: toSeverityValue(severity) }), + ...(expirationDate && { expirationDate: expirationDate.toISOString() }), + } + await onConfirm(input) + if (isMountedRef.current) { + setDescription("") + setExpirationDate(null) + onClose() + } + } catch (error) { + console.error("Failed to create remediation:", error) + // Error is shown in modal via errorMessage from parent + } finally { + if (isMountedRef.current) { + setIsSubmitting(false) + } + } + } + + const handleClose = () => { + setDescription("") + setExpirationDate(null) + setDescriptionError("") + onClose() + } + + const handleDescriptionChange = (e: React.ChangeEvent) => { + setDescription(e.target.value) + // Clear error when user starts typing + if (descriptionError) { + setDescriptionError("") + } + } + + return ( + + + + + + + } + > + + {errorMessage && } + + Vulnerability: {vulnerability} + + + Service: {service} + + + Image: {image} + + + setExpirationDate(dates?.[0] ?? null)} + minDate="today" + helptext="Optional. When this false positive should no longer be considered valid." + /> + + + + + + + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index 83cd2933bc..355eb454f6 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -4,12 +4,23 @@ */ import React, { useState } from "react" -import { DataGridRow, DataGridCell, Stack } from "@cloudoperators/juno-ui-components" +import { + DataGridRow, + DataGridCell, + Stack, + PopupMenu, + PopupMenuOptions, + PopupMenuItem, +} from "@cloudoperators/juno-ui-components" import { Icon } from "@cloudoperators/juno-ui-components" import { IssueIcon } from "../../../../../common/IssueIcon" import { IssueTimestamp } from "../../../../../common/IssueTimestamp" import { ImageVulnerability } from "../../../../../Services/utils" import { getSeverityColor, useTextOverflow } from "../../../../../../utils" +import { FalsePositiveModal } from "../../../FalsePositiveModal" +import { useRouteContext } from "@tanstack/react-router" +import { createRemediation } from "../../../../../../api/createRemediation" +import { RemediationInput } from "../../../../../../generated/graphql" const cellSeverityClasses = (severity: string) => { const borderColor = getSeverityColor(severity.toLowerCase()) @@ -24,56 +35,117 @@ const cellSeverityClasses = (severity: string) => { type IssuesDataRowProps = { issue: ImageVulnerability + service: string + image: string + showFalsePositiveAction?: boolean + onFalsePositiveSuccess?: (cveNumber: string) => void | Promise } -export const IssuesDataRow = ({ issue }: IssuesDataRowProps) => { +export const IssuesDataRow = ({ + issue, + service, + image, + showFalsePositiveAction = true, + onFalsePositiveSuccess, +}: IssuesDataRowProps) => { const [isExpanded, setIsExpanded] = useState(false) - const { needsExpansion, textRef } = useTextOverflow(issue.description) + const [isModalOpen, setIsModalOpen] = useState(false) + const [createError, setCreateError] = useState(null) + const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") + const { apiClient } = useRouteContext({ from: "/services/$service" }) + + if (!issue || !issue.name) { + return null + } const toggleDescription = (e: React.MouseEvent) => { e.preventDefault() setIsExpanded(!isExpanded) } + const handleFalsePositiveClick = () => { + setIsModalOpen(true) + } + + const handleModalConfirm = async (input: RemediationInput) => { + setCreateError(null) + try { + await createRemediation({ apiClient, input }) + setIsModalOpen(false) + const cveNumber = issue?.name || "unknown" + await onFalsePositiveSuccess?.(cveNumber) + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Failed to create remediation") + } + } + + const handleModalClose = () => { + setCreateError(null) + setIsModalOpen(false) + } + return ( - - - - - - + <> + + + + + + - - - {issue.name} - {issue.sourceUrl && issue.sourceUrl !== "-" && ( - - - - Vulnerability source - - - )} - - - - - - - - - {issue.description} - - {issue.description && needsExpansion && ( - - - {isExpanded ? "Show less" : "Show more"} - - - - )} - - - + + + {issue.name} + {issue.sourceUrl && issue.sourceUrl !== "-" && ( + + + + Vulnerability source + + + )} + + + + + + + + + {issue.description} + + {issue.description && needsExpansion && ( + + + {isExpanded ? "Show less" : "Show more"} + + + + )} + + + {showFalsePositiveAction && ( + e.stopPropagation()}> + + + + + + + )} + + {showFalsePositiveAction && ( + + )} + > ) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx index 37e73be601..445694e0a5 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/index.tsx @@ -4,29 +4,40 @@ */ import React, { use } from "react" -import { ApolloQueryResult } from "@apollo/client" +import { ObservableQuery } from "@apollo/client" import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" import { IssuesDataRow } from "./IssuesDataRow" import { getNormalizedImageVulnerabilitiesResponse } from "../../../../Services/utils" import { GetImagesQuery } from "../../../../../generated/graphql" type IssuesDataRowsProps = { - issuesPromise: Promise> + issuesPromise: Promise> + service: string + image: string + onFalsePositiveSuccess: (cveNumber: string) => void | Promise } -export const IssuesDataRows = ({ issuesPromise }: IssuesDataRowsProps) => { +export const IssuesDataRows = ({ issuesPromise, service, image, onFalsePositiveSuccess }: IssuesDataRowsProps) => { const { error, data } = use(issuesPromise) const { vulnerabilities } = getNormalizedImageVulnerabilitiesResponse(data as GetImagesQuery | undefined) if (error) { - return Error loading vulnerabilities: {error.message} + return Error loading vulnerabilities: {error.message} } if (vulnerabilities.length === 0) { - return No vulnerabilities found! 🚀 + return No vulnerabilities found! 🚀 } - return vulnerabilities.map((vulnerability) => ( - - )) + return vulnerabilities + .filter((vulnerability) => vulnerability && vulnerability.name) + .map((vulnerability) => ( + + )) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/RemediatedIssueDataRow.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/RemediatedIssueDataRow.test.tsx new file mode 100644 index 0000000000..e5956f3035 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/RemediatedIssueDataRow.test.tsx @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { RemediatedIssueDataRow } from "./index" +import type { ImageVulnerability } from "../../../../../Services/utils" + +const mockIssue: ImageVulnerability = { + id: "vul-1", + name: "CVE-2024-1234", + severity: "High", + earliestTargetRemediationDate: "2024-12-31T00:00:00Z", + description: "A test vulnerability description.", + sourceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2024-1234", +} + +describe("RemediatedIssueDataRow", () => { + it("renders issue name and description", () => { + render( {}} />) + expect(screen.getByText("CVE-2024-1234")).toBeInTheDocument() + expect(screen.getByText("A test vulnerability description.")).toBeInTheDocument() + }) + + it("calls onSelect when row is clicked", async () => { + const onSelect = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByText("CVE-2024-1234")) + expect(onSelect).toHaveBeenCalledTimes(1) + }) + + it("returns null when issue has no name", () => { + const { container } = render( {}} />) + expect(container.firstChild).toBeNull() + }) +}) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx new file mode 100644 index 0000000000..ec3091b403 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from "react" +import { DataGridRow, DataGridCell, Stack } from "@cloudoperators/juno-ui-components" +import { Icon } from "@cloudoperators/juno-ui-components" +import { IssueIcon } from "../../../../../common/IssueIcon" +import { IssueTimestamp } from "../../../../../common/IssueTimestamp" +import { ImageVulnerability } from "../../../../../Services/utils" +import { getSeverityColor, useTextOverflow } from "../../../../../../utils" + +const cellSeverityClasses = (severity: string) => { + const borderColor = getSeverityColor(severity.toLowerCase()) + return ` + border-l-2 + ${borderColor} + h-full + pl-5 + ` +} + +type RemediatedIssueDataRowProps = { + issue: ImageVulnerability + selected?: boolean + onSelect: () => void +} + +export const RemediatedIssueDataRow = ({ issue, selected, onSelect }: RemediatedIssueDataRowProps) => { + const [isExpanded, setIsExpanded] = useState(false) + const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") + + const toggleDescription = (e: React.MouseEvent) => { + e.preventDefault() + setIsExpanded((prev) => !prev) + } + + if (!issue?.name) { + return null + } + + return ( + + + + + + + + + + {issue.name} + {issue.sourceUrl && issue.sourceUrl !== "-" && ( + e.stopPropagation()} + > + + + Vulnerability source + + + )} + + + + + + + + + {issue.description} + + {issue.description && needsExpansion && ( + { + e.stopPropagation() + toggleDescription(e) + }} + className="link-hover" + > + + {isExpanded ? "Show less" : "Show more"} + + + + )} + + + + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssuesDataRows.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssuesDataRows.test.tsx new file mode 100644 index 0000000000..f0774125b3 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssuesDataRows.test.tsx @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Suspense } from "react" +import { act, render, screen } from "@testing-library/react" +import { ObservableQuery } from "@apollo/client" +import { RemediatedIssuesDataRows } from "./index" +import { GetImagesQuery, GetRemediationsQuery } from "../../../../../generated/graphql" + +const emptyImagesPromise = Promise.resolve({ + data: { + Images: { + edges: [], + pageInfo: { pageNumber: 1, pages: [], __typename: "PageInfo" }, + __typename: "ImageConnection", + }, + }, + loading: false, + networkStatus: 7, + error: undefined, + partial: false, + dataState: "complete" as const, +}) as unknown as Promise> + +const emptyRemediationsPromise = Promise.resolve({ + data: { + Remediations: { + edges: [], + pageInfo: { pageNumber: 1, pages: [], __typename: "PageInfo" }, + __typename: "RemediationConnection", + }, + }, + loading: false, + networkStatus: 7, + error: undefined, + partial: false, + dataState: "complete" as const, +}) as unknown as Promise> + +describe("RemediatedIssuesDataRows", () => { + it("renders empty state when there are no remediated vulnerabilities", async () => { + await act(async () => { + render( + Loading...}> + {}} + /> + + ) + }) + expect(await screen.findByText("No remediated vulnerabilities found!")).toBeInTheDocument() + }) +}) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx new file mode 100644 index 0000000000..5c67b81ec5 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { use } from "react" +import { ObservableQuery } from "@apollo/client" +import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" +import { RemediatedIssueDataRow } from "./RemediatedIssueDataRow" +import { getNormalizedRemediationsResponse } from "../../../../Services/utils" +import { getNormalizedImageVulnerabilitiesResponse } from "../../../../Services/utils" +import { GetRemediationsQuery } from "../../../../../generated/graphql" +import { GetImagesQuery } from "../../../../../generated/graphql" + +const COLUMN_SPAN = 4 + +type RemediatedIssuesDataRowsProps = { + issuesPromise: Promise> + remediationsPromise: Promise> + selectedVulnerabilityName: string | null + onSelectVulnerability: (vulnerabilityName: string | null) => void +} + +export const RemediatedIssuesDataRows = ({ + issuesPromise, + remediationsPromise, + selectedVulnerabilityName, + onSelectVulnerability, +}: RemediatedIssuesDataRowsProps) => { + const issuesResult = use(issuesPromise) + const remediationsResult = use(remediationsPromise) + const { error: issuesError, data: issuesData } = issuesResult + const { data: remediationsData } = remediationsResult + // Support both Apollo result (.data) and raw query data (e.g. from React Query cache) + const queryData = (issuesData ?? (issuesResult as unknown as GetImagesQuery)) as GetImagesQuery | undefined + const { vulnerabilities } = getNormalizedImageVulnerabilitiesResponse(queryData) + // Keep remediations loaded for future remediation-actions panel + getNormalizedRemediationsResponse(remediationsData as GetRemediationsQuery | undefined) + if (issuesError) { + return ( + + Error loading remediated vulnerabilities: {issuesError.message} + + ) + } + + if (vulnerabilities.length === 0) { + return No remediated vulnerabilities found! + } + + return ( + <> + {vulnerabilities.map((issue) => ( + onSelectVulnerability(issue.name)} + /> + ))} + > + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx new file mode 100644 index 0000000000..28843a5043 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/RemediationHistoryPanel.test.tsx @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { render, screen } from "@testing-library/react" +import { createMemoryHistory, createRootRoute, createRoute, Outlet, RouterProvider } from "@tanstack/react-router" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { RemediationHistoryPanel } from "./index" +import { getTestRouter } from "../../../../../mocks/getTestRouter" + +const renderPanel = (vulnerability: string | null = null) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/services/$service", + component: () => ( + {}} /> + ), + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = getTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ["/services/my-service"], + }), + }) + return render( + + + + ) +} + +describe("RemediationHistoryPanel", () => { + it("renders without crashing when vulnerability is null (panel closed)", () => { + renderPanel(null) + expect(screen.queryByText("Revert False Positive")).not.toBeInTheDocument() + }) +}) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx new file mode 100644 index 0000000000..8877c220f2 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, Suspense, use, useState } from "react" +import { useRouteContext } from "@tanstack/react-router" +import { + Panel, + PanelBody, + DataGrid, + DataGridRow, + DataGridHeadCell, + DataGridCell, + PopupMenu, + PopupMenuOptions, + PopupMenuItem, + Toast, + Stack, +} from "@cloudoperators/juno-ui-components" +import { fetchRemediations } from "../../../../../api/fetchRemediations" +import { deleteRemediation } from "../../../../../api/deleteRemediation" +import { getNormalizedRemediationsResponse } from "../../../../Services/utils" +import { GetRemediationsQuery } from "../../../../../generated/graphql" +import { ErrorBoundary } from "../../../../common/ErrorBoundary" +import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" +import { LoadingDataRow } from "../../../../common/LoadingDataRow" +import { getErrorDataRowComponent } from "../../../../common/getErrorDataRow" +import type { RemediatedVulnerability } from "../../../../Services/utils" + +type RemediationHistoryPanelProps = { + service: string + image: string + vulnerability: string | null + onClose: () => void + /** Called after a successful revert so the parent can refetch getRemediations and getImages. */ + onRevertSuccess?: () => void | Promise +} + +const COLUMN_SPAN = 6 + +function formatDateTime(value: string | null): string { + if (!value) return "—" + try { + const d = new Date(value) + return Number.isNaN(d.getTime()) + ? value + : d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" }) + } catch { + return value + } +} + +const RemediationHistoryTable = ({ + remediationsPromise, + onRevert, +}: { + remediationsPromise: ReturnType + onRevert: (remediationId: string) => Promise +}) => { + const [revertingId, setRevertingId] = useState(null) + const { error, data } = use(remediationsPromise) + const { remediatedVulnerabilities } = getNormalizedRemediationsResponse(data as GetRemediationsQuery | undefined) + + const handleRevert = async (r: RemediatedVulnerability) => { + setRevertingId(r.remediationId) + try { + await onRevert(r.remediationId) + } finally { + setRevertingId(null) + } + } + + if (error) { + return Error loading remediations: {error.message} + } + + if (remediatedVulnerabilities.length === 0) { + return No remediations found for this vulnerability. + } + + return ( + <> + {remediatedVulnerabilities.map((r: RemediatedVulnerability) => ( + + {formatDateTime(r.expirationDate)} + {formatDateTime(r.remediationDate)} + {r.remediatedBy ?? "—"} + {r.type ?? "—"} + {r.description ?? "—"} + e.stopPropagation()}> + + + handleRevert(r)} + disabled={!!revertingId} + /> + + + + + ))} + > + ) +} + +type RevertMessage = { variant: "success" | "error"; text: string } + +export const RemediationHistoryPanel = ({ + service, + image, + vulnerability, + onClose, + onRevertSuccess, +}: RemediationHistoryPanelProps) => { + const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) + const [revertMessage, setRevertMessage] = useState(null) + + const remediationsPromise = useMemo(() => { + if (!vulnerability) return null + + return fetchRemediations({ + apiClient, + queryClient, + filter: { + service: [service], + image: [image], + vulnerability: [vulnerability], + }, + }) + }, [service, image, vulnerability, apiClient, queryClient]) + + const handleRevert = async (remediationId: string) => { + try { + await deleteRemediation({ apiClient, remediationId }) + await onRevertSuccess?.() + const text = `Vulnerability ${vulnerability ?? "unknown"} reverted from false positive successfully.` + setRevertMessage({ variant: "success", text }) + } catch (err) { + setRevertMessage({ + variant: "error", + text: err instanceof Error ? err.message : "Failed to delete remediation", + }) + } + } + + return ( + + + {revertMessage && ( + + setRevertMessage(null)} + > + {revertMessage.text} + + + )} + {remediationsPromise && ( + + + + Expiration Date + Remediation Date + Remediated By + Type + Description + Actions + + }> + + + + + )} + + + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 2b882f7f66..916f2102e6 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Suspense, useState } from "react" -import { useRouteContext } from "@tanstack/react-router" +import React, { Suspense, useState, useCallback, useEffect } from "react" +import { useNavigate, useRouteContext } from "@tanstack/react-router" import { DataGrid, DataGridRow, @@ -12,44 +12,58 @@ import { Icon, Stack, SearchInput, - ContentHeading, + Tabs, + TabList, + Tab, + TabPanel, + Message, } from "@cloudoperators/juno-ui-components" import { getNormalizedImageVulnerabilitiesResponse, ServiceImage } from "../../../Services/utils" +import type { VulnerabilityFilter } from "../../../../generated/graphql" import { fetchImages } from "../../../../api/fetchImages" +import { fetchRemediations } from "../../../../api/fetchRemediations" import { IssuesDataRows } from "./IssuesDataRows" +import { RemediatedIssuesDataRows } from "./RemediatedIssuesDataRows" +import { RemediationHistoryPanel } from "./RemediationHistoryPanel" import { CursorPagination } from "../../../common/CursorPagination" import { ErrorBoundary } from "../../../common/ErrorBoundary" import { getErrorDataRowComponent } from "../../../common/getErrorDataRow" import { LoadingDataRow } from "../../../common/LoadingDataRow" +import type { VulnerabilitiesTabValue } from "../index" type ImageIssuesListProps = { service: string image: ServiceImage + vulnerabilitiesTab?: VulnerabilitiesTabValue + /** CVE number when remediation history panel is open (from vulRemediations search param). */ + vulRemediations?: string } -export const ImageIssuesList = ({ service, image }: ImageIssuesListProps) => { - const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) - const [searchTerm, setSearchTerm] = useState(undefined) - const [pageCursor, setPageCursor] = useState(undefined) - - const vulFilter = searchTerm ? { search: [searchTerm] } : undefined - - const issuesPromise = fetchImages({ - apiClient, - queryClient, - filter: { - service: [service], - repository: [image.repository], - }, - firstVulnerabilities: 20, - afterVulnerabilities: pageCursor, - vulFilter, - }) - +const VulnerabilitiesTabContent = ({ + service, + image, + setSearchTerm, + setPageCursor, + issuesPromise, + successMessage, + onFalsePositiveSuccess, +}: { + service: string + image: ServiceImage + setSearchTerm: (term: string | undefined) => void + setPageCursor: (cursor: string | null | undefined) => void + issuesPromise: ReturnType + successMessage: string | null + onFalsePositiveSuccess: (cveNumber: string) => void | Promise +}) => { return ( <> - - Vulnerabilities + {successMessage && ( + + + + )} + { }} /> - + @@ -67,16 +81,22 @@ export const ImageIssuesList = ({ service, image }: ImageIssuesListProps) => { Vulnerability Target Date Description + Actions {issuesPromise && ( - }> - + }> + )} @@ -95,3 +115,256 @@ export const ImageIssuesList = ({ service, image }: ImageIssuesListProps) => { > ) } + +const RemediatedVulnerabilitiesTabContent = ({ + service, + image, + setSearchTerm, + issuesPromise, + remediationsPromise, + setPageCursor, + successMessage, + onDataRefresh, + selectedVulnerability, + onSelectVulnerability, +}: { + service: string + image: string + setSearchTerm: (term: string | undefined) => void + issuesPromise: ReturnType + remediationsPromise: ReturnType + setPageCursor: (cursor: string | null | undefined) => void + successMessage: string | null + onDataRefresh?: () => void | Promise + selectedVulnerability: string | null + onSelectVulnerability: (cve: string | null) => void +}) => { + return ( + <> + {successMessage && ( + + + + )} + + setSearchTerm(search || "")} + onClear={() => setSearchTerm("")} + /> + + + + + + + + Vulnerability + Target Date + Description + + + {issuesPromise && ( + + }> + + + + )} + + {issuesPromise && ( + + + + + + )} + + onSelectVulnerability(null)} + onRevertSuccess={onDataRefresh} + /> + > + ) +} + +const SUCCESS_MESSAGE_DURATION_MS = 5000 + +export const ImageIssuesList = ({ + service, + image, + vulnerabilitiesTab = "active", + vulRemediations, +}: ImageIssuesListProps) => { + const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) + const navigate = useNavigate() + const [searchTerm, setSearchTerm] = useState(undefined) + const [remediatedSearchTerm, setRemediatedSearchTerm] = useState(undefined) + const [pageCursor, setPageCursor] = useState(undefined) + const [remediatedPageCursor, setRemediatedPageCursor] = useState(undefined) + const selectedTabIndex = vulnerabilitiesTab === "remediated" ? 1 : 0 + const handleTabSelect = useCallback( + (index: number) => { + const list: VulnerabilitiesTabValue = index === 1 ? "remediated" : "active" + navigate({ + to: "/services/$service/images/$image", + params: { service, image: image.repository }, + search: { vulnerabilitiesList: list, vulRemediations: list === "remediated" ? vulRemediations : undefined }, + replace: true, + }) + }, + [navigate, service, image.repository, vulRemediations] + ) + const handleRemediationPanelVulnerabilityChange = useCallback( + (cve: string | null) => { + navigate({ + to: "/services/$service/images/$image", + params: { service, image: image.repository }, + search: { vulnerabilitiesList: "remediated", vulRemediations: cve ?? undefined }, + replace: true, + }) + }, + [navigate, service, image.repository] + ) + const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useState(null) + const [remediatedSuccessMessage, setRemediatedSuccessMessage] = useState(null) + const [, setRefreshKey] = useState(0) + + const refreshIssuesData = useCallback(async () => { + await queryClient.refetchQueries({ queryKey: ["remediations"] }) + await queryClient.refetchQueries({ queryKey: ["images"] }) + setRefreshKey((k) => k + 1) + }, [queryClient]) + + const openVulFilter = { + status: "open", + ...(searchTerm ? { search: [searchTerm] } : {}), + } + const remediatedVulFilter = { + status: "remediated", + ...(remediatedSearchTerm ? { search: [remediatedSearchTerm] } : {}), + } + + const issuesPromise = fetchImages({ + apiClient, + queryClient, + filter: { + service: [service], + repository: [image.repository], + }, + firstVulnerabilities: 20, + afterVulnerabilities: pageCursor, + vulFilter: openVulFilter as VulnerabilityFilter, + }) + + const remediatedIssuesPromise = fetchImages({ + apiClient, + queryClient, + filter: { + service: [service], + repository: [image.repository], + }, + firstVulnerabilities: 20, + afterVulnerabilities: remediatedPageCursor, + vulFilter: remediatedVulFilter as VulnerabilityFilter, + }) + + useEffect(() => { + setRemediatedPageCursor(undefined) + }, [remediatedSearchTerm]) + + const remediationsPromise = fetchRemediations({ + apiClient, + queryClient, + filter: { + service: [service], + image: [image.repository], + }, + }) + + useEffect(() => { + if (!vulnerabilitiesSuccessMessage) return + const timer = setTimeout(() => setVulnerabilitiesSuccessMessage(null), SUCCESS_MESSAGE_DURATION_MS) + return () => clearTimeout(timer) + }, [vulnerabilitiesSuccessMessage]) + + useEffect(() => { + if (!remediatedSuccessMessage) return + const timer = setTimeout(() => setRemediatedSuccessMessage(null), SUCCESS_MESSAGE_DURATION_MS) + return () => clearTimeout(timer) + }, [remediatedSuccessMessage]) + + const VulnerabilitiesTab = () => { + const handleFalsePositiveSuccess = useCallback( + async (cveNumber: string) => { + await refreshIssuesData() + const text = `Vulnerability ${cveNumber} marked as false positive successfully.` + setVulnerabilitiesSuccessMessage(text) + }, + [refreshIssuesData] + ) + + return ( + + ) + } + + const RemediatedVulnerabilitiesTab = () => { + return ( + + ) + } + + return ( + <> + + + + + + + + + + + + + > + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageVersionOccurrences.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageVersionOccurrences.tsx index 6e29f9f8e6..ce6fa49d26 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageVersionOccurrences.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageVersionOccurrences.tsx @@ -99,7 +99,7 @@ const ImageVersionOccurrences = ({ imageVersion }: ImageVersionOccurrencesProps) ))} - + {displayOccurrences ? "Hide Occurrences" : "Display Occurrences"} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageVersionsList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageVersionsList/index.tsx new file mode 100644 index 0000000000..37ea8ab249 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/ImageVersionsList/index.tsx @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from "react" +import { Stack, Icon } from "@cloudoperators/juno-ui-components" +import { useNavigate } from "@tanstack/react-router" +import { ImageVersion } from "../../../Services/utils" +import SectionContentHeading from "../../../common/SectionContentHeading" +import { getShortSha256 } from "../../../../utils" + +const VERSIONS_INITIAL = 30 + +type ImageVersionsListProps = { + versions: ImageVersion[] + service: string + imageRepository: string +} + +export const ImageVersionsList = ({ versions, service, imageRepository }: ImageVersionsListProps) => { + const [showAllVersions, setShowAllVersions] = useState(false) + const navigate = useNavigate() + const displayedVersions = showAllVersions ? versions : versions.slice(0, VERSIONS_INITIAL) + const hasMoreVersions = versions.length > VERSIONS_INITIAL + + const handleVersionClick = (version: string) => { + navigate({ + to: "/services/$service/images/$image/versions/$version", + params: { + service: encodeURIComponent(service), + image: encodeURIComponent(imageRepository), + version: encodeURIComponent(version), + }, + search: {}, + }) + } + + if (!versions || versions.length === 0) { + return null + } + + return ( + <> + Image Versions + + {displayedVersions.map((version) => ( + { + e.preventDefault() + handleVersionClick(version.version) + }} + className="link-hover w-fit" + > + {getShortSha256(version.version)} + + ))} + + {hasMoreVersions && ( + { + e.preventDefault() + setShowAllVersions((prev) => !prev) + }} + className="link-hover mt-2 inline-flex items-center gap-1" + > + {showAllVersions ? ( + + Show less + + + ) : ( + + Show more ({versions.length - VERSIONS_INITIAL} more) + + + )} + + )} + > + ) +} diff --git a/apps/heureka/src/components/Service/ImageDetails/index.tsx b/apps/heureka/src/components/Service/ImageDetails/index.tsx index 3ff52d7884..006d33dae5 100644 --- a/apps/heureka/src/components/Service/ImageDetails/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/index.tsx @@ -3,29 +3,70 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { use } from "react" -import { Stack, Pill, DataGrid, DataGridRow, DataGridHeadCell, DataGridCell } from "@cloudoperators/juno-ui-components" +import React, { use, useState } from "react" +import { + Stack, + Pill, + DataGrid, + DataGridRow, + DataGridHeadCell, + DataGridCell, + Icon, +} from "@cloudoperators/juno-ui-components" +import { useNavigate } from "@tanstack/react-router" import { getNormalizedImagesResponse, ServiceImage } from "../../Services/utils" +import { getShortSha256 } from "../../../utils" import { IssueCountsPerSeverityLevel } from "../../common/IssueCountsPerSeverityLevel" import SectionContentHeading from "../../common/SectionContentHeading" import { ImageIssuesList } from "./ImageIssuesList" -import { GetImagesQuery, GetImagesQueryResult } from "../../../generated/graphql" +import { ObservableQuery } from "@apollo/client" +import { GetImagesQuery } from "../../../generated/graphql" + +export type VulnerabilitiesTabValue = "active" | "remediated" type ImageDetailsProps = { - imagesPromise: Promise + imagesPromise: Promise> imageRepository: string service: string + vulnerabilitiesTab?: VulnerabilitiesTabValue + /** CVE number when remediation history panel is open (from vulRemediations search param). */ + vulRemediations?: string } -export const ImageDetails = ({ imagesPromise, imageRepository, service }: ImageDetailsProps) => { +export const ImageDetails = ({ + imagesPromise, + imageRepository, + service, + vulnerabilitiesTab = "active", + vulRemediations, +}: ImageDetailsProps) => { const { data } = use(imagesPromise) const { images } = getNormalizedImagesResponse(data as GetImagesQuery | undefined) const image = images.find((img: ServiceImage) => img.repository === imageRepository) + const [showAllVersions, setShowAllVersions] = useState(false) + const navigate = useNavigate() if (!image) { return null } + const handleVersionClick = (version: string) => { + navigate({ + to: "/services/$service/images/$image/versions/$version", + params: { + service: encodeURIComponent(service), + image: encodeURIComponent(imageRepository), + version: encodeURIComponent(version), + }, + search: {}, + }) + } + + const versions = image.versions || [] + const VERSIONS_INITIAL = 30 + const displayedVersions = showAllVersions ? versions : versions.slice(0, VERSIONS_INITIAL) + const hasMoreVersions = versions.length > VERSIONS_INITIAL + return ( <> Image {image.repository} @@ -59,10 +100,62 @@ export const ImageDetails = ({ imagesPromise, imageRepository, service }: ImageD + {versions.length > 0 && ( + + Versions ({versions.length}) + + + {displayedVersions.map((version) => ( + { + e.preventDefault() + handleVersionClick(version.version) + }} + className="link-hover w-fit" + > + {getShortSha256(version.version)} + + ))} + + {hasMoreVersions && ( + { + e.preventDefault() + setShowAllVersions((prev) => !prev) + }} + className="link-hover mt-2 inline-flex items-center gap-1" + > + {showAllVersions ? ( + <> + Show less ... + + > + ) : ( + <> + Show ({versions.length - VERSIONS_INITIAL} more ...) + + > + )} + + )} + + + )} {/* Issues List Section */} - {service && imageRepository && image && } + {service && imageRepository && image && ( + + )} > ) } diff --git a/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/ImageVersionIssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/ImageVersionIssuesDataRows/index.tsx new file mode 100644 index 0000000000..5b65ddeedf --- /dev/null +++ b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/ImageVersionIssuesDataRows/index.tsx @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { use } from "react" +import { ObservableQuery } from "@apollo/client" +import { GetImageVersionsQuery } from "../../../../../generated/graphql" +import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" +import { IssuesDataRow } from "../../../ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow" +import { getNormalizedImageVersionDetailsResponse } from "../../../../Services/utils" + +type ImageVersionIssuesDataRowsProps = { + issuesPromise: Promise> + service: string + image: string +} + +export const ImageVersionIssuesDataRows = ({ issuesPromise, service, image }: ImageVersionIssuesDataRowsProps) => { + const { error, data } = use(issuesPromise) + const { imageVersion: versionDetails } = getNormalizedImageVersionDetailsResponse( + data as GetImageVersionsQuery | undefined + ) + + if (error) { + return Error loading vulnerabilities: {error.message} + } + + if (!versionDetails || !versionDetails.vulnerabilities || versionDetails.vulnerabilities.length === 0) { + return No vulnerabilities found! 🚀 + } + + return ( + <> + {versionDetails.vulnerabilities.map((vulnerability) => ( + + ))} + > + ) +} diff --git a/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/index.tsx new file mode 100644 index 0000000000..61241f4998 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionIssuesList/index.tsx @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Suspense } from "react" +import { + DataGrid, + DataGridRow, + DataGridHeadCell, + Icon, + Stack, + ContentHeading, +} from "@cloudoperators/juno-ui-components" +import { ObservableQuery } from "@apollo/client" +import { GetImageVersionsQuery } from "../../../../generated/graphql" +import { ImageVersionIssuesDataRows } from "./ImageVersionIssuesDataRows" +import { ErrorBoundary } from "../../../common/ErrorBoundary" +import { getErrorDataRowComponent } from "../../../common/getErrorDataRow" +import { LoadingDataRow } from "../../../common/LoadingDataRow" + +type ImageVersionIssuesListProps = { + issuesPromise: Promise> + service: string + image: string +} + +export const ImageVersionIssuesList = ({ issuesPromise, service, image }: ImageVersionIssuesListProps) => { + return ( + <> + + Vulnerabilities + + + + + + + Vulnerability + Target Date + Description + + + {issuesPromise && ( + + }> + + + + )} + + > + ) +} diff --git a/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionOccurrences.tsx b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionOccurrences.tsx new file mode 100644 index 0000000000..1b9710b8e6 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageVersionDetails/ImageVersionOccurrences.tsx @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, MouseEvent, useEffect } from "react" +import { Box, Stack, Tooltip, TooltipTrigger, TooltipContent, Icon } from "@cloudoperators/juno-ui-components" +import { ServiceImageVersion, ComponentInstance } from "../../Services/utils" + +type ToolTipBoxType = { + instance: ComponentInstance +} + +const BoxWithTooltip = ({ instance }: ToolTipBoxType) => ( + + + {instance?.pod || "--"} + + + + + + + Namespace: {instance?.namespace || "--"} + Region: {instance?.region || "--"} + Cluster: {instance?.cluster || "--"} + Container: {instance?.container || "--"} + Pod: {instance?.pod || "--"} + + + + + {instance?.cluster || "--"} + +) + +/** + * Groups instances by namespace and container, and calculates the minimum width for the grid based on the longest pod name. + * Also limits the number of instances displayed to a maximum value. + * @param instances + * @param maxInstances + * @returns + */ +const groupInstances = (instances: ComponentInstance[]) => { + const grouped: Record> = {} // Namespace -> Container -> Instances + + instances.forEach((instance) => { + // Use parsed fields from ccrn + const namespace = instance.namespace || "Unknown" + const container = instance.container || "All" + + if (!grouped[namespace]) grouped[namespace] = {} + if (!grouped[namespace][container]) grouped[namespace][container] = [] + + grouped[namespace][container].push(instance) + }) + + return grouped +} + +type ImageVersionOccurrencesProps = { + imageVersion: ServiceImageVersion +} + +const ImageVersionOccurrences = ({ imageVersion }: ImageVersionOccurrencesProps) => { + const [displayOccurrences, setDisplayOccurrences] = useState(false) + const componentInstances = imageVersion.componentInstances || [] + const grouped = groupInstances(componentInstances) + + useEffect(() => { + // Reset state when myProp changes + setDisplayOccurrences(false) + }, [imageVersion]) + + const onShowMoreClicked = (e: MouseEvent) => { + e.preventDefault() + setDisplayOccurrences(!displayOccurrences) + } + + return ( + <> + {displayOccurrences && + grouped && + Object.entries(grouped).map(([namespace, containers]) => ( + + {Object.entries(containers).map(([container, instances]) => ( + + + Namespace {namespace} - Container {container} + + + {instances.map((item, i) => ( + + ))} + + + ))} + + ))} + + + + + {displayOccurrences ? "Hide Occurrences" : "Display Occurrences"} + + + + + > + ) +} + +export default ImageVersionOccurrences diff --git a/apps/heureka/src/components/Service/ImageVersionDetails/index.tsx b/apps/heureka/src/components/Service/ImageVersionDetails/index.tsx new file mode 100644 index 0000000000..d86a8c7a30 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageVersionDetails/index.tsx @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { use } from "react" +import { Stack, Pill, DataGrid, DataGridRow, DataGridHeadCell, DataGridCell } from "@cloudoperators/juno-ui-components" +import { getNormalizedImageVersionsResponse } from "../../Services/utils" +import ImageVersionOccurrences from "./ImageVersionOccurrences" +import { IssueCountsPerSeverityLevel } from "../../common/IssueCountsPerSeverityLevel" +import SectionContentHeading from "../../common/SectionContentHeading" +import { ImageVersionIssuesList } from "./ImageVersionIssuesList" +import { ObservableQuery } from "@apollo/client" +import { GetImageVersionsQuery } from "../../../generated/graphql" +import { getShortSha256 } from "../../../utils" + +type ImageVersionDetailsProps = { + imageVersionsPromise: Promise> + imageVersion: string + service: string + imageRepository: string +} + +export const ImageVersionDetails = ({ + imageVersionsPromise, + imageVersion: selectedImageVersion, + service, + imageRepository, +}: ImageVersionDetailsProps) => { + const { data, error } = use(imageVersionsPromise) + + if (error) { + return Error loading image version: {error.message} + } + + const { imageVersions } = getNormalizedImageVersionsResponse(data as GetImageVersionsQuery | undefined) + const imageVersion = imageVersions[0] + + if (!imageVersion) { + return ( + + Image version not found: {selectedImageVersion} + Available versions: {imageVersions.map((v) => v.version).join(", ") || "none"} + Data: {JSON.stringify(data, null, 2)} + + ) + } + + return ( + <> + + Image {imageVersion.repository || imageRepository} - Version {getShortSha256(imageVersion.version)} + + + + + Details + + + {imageVersion.tag && ( + + )} + + + + + + + Vulnerabilities Counts + + + + + + {`Occurrences (${imageVersion.componentInstancesCount || 0})`} + + + + + + + {/* Second Section: Issues List */} + {service && selectedImageVersion && imageVersion && ( + + )} + > + ) +} diff --git a/apps/heureka/src/components/Service/index.tsx b/apps/heureka/src/components/Service/index.tsx index 5fe33ce23a..e8adc227ac 100644 --- a/apps/heureka/src/components/Service/index.tsx +++ b/apps/heureka/src/components/Service/index.tsx @@ -6,10 +6,11 @@ import React, { Suspense, useEffect, useState } from "react" import { Outlet, useLoaderData, useMatchRoute, useNavigate, useParams, useRouteContext } from "@tanstack/react-router" import { Spinner } from "@cloudoperators/juno-ui-components" +import { ObservableQuery } from "@apollo/client" import { ServiceImages } from "../common/ServiceImages" import { ServiceDetails } from "./ServiceDetails" import { fetchImages } from "../../api/fetchImages" -import { GetImagesQueryResult } from "../../generated/graphql" +import { GetImagesQuery } from "../../generated/graphql" import { ErrorBoundary } from "../common/ErrorBoundary" export const Service = () => { @@ -18,14 +19,22 @@ export const Service = () => { const { servicePromise } = useLoaderData({ from: "/services/$service" }) const { service } = useParams({ from: "/services/$service" }) const [pageCursor, setPageCursor] = useState(undefined) - const [imagesPromise, setImagesPromise] = useState | undefined>(undefined) + const [imagesPromise, setImagesPromise] = useState> | undefined>( + undefined + ) - // Check if we're on a child route (image details page) + // Check if we're on a child route (image details page or version details page) const matchRoute = useMatchRoute() const isOnImageDetailsPage = matchRoute({ to: "/services/$service/images/$image" }) + const isOnVersionDetailsPage = matchRoute({ to: "/services/$service/images/$image/versions/$version" }) - // refetch images only when the page cursor changes + // refetch images only when the page cursor changes, but not when on version details page useEffect(() => { + // Don't fetch images if we're on the version details page + if (isOnVersionDetailsPage) { + return + } + const promise = fetchImages({ queryClient, apiClient, @@ -35,10 +44,10 @@ export const Service = () => { after: pageCursor, }) setImagesPromise(promise) - }, [pageCursor, service, queryClient, apiClient]) + }, [pageCursor, service, queryClient, apiClient, isOnVersionDetailsPage]) - // If we're on a child route (image details), just render the outlet - if (isOnImageDetailsPage) { + // If we're on a child route (image details or version details), just render the outlet + if (isOnImageDetailsPage || isOnVersionDetailsPage) { return } diff --git a/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/ServiceDataRow.tsx b/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/ServiceDataRow.tsx index bc29591b65..0648d8ce02 100644 --- a/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/ServiceDataRow.tsx +++ b/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/ServiceDataRow.tsx @@ -4,7 +4,7 @@ */ import React from "react" -import { DataGridRow, DataGridCell, Pill, Stack, Button } from "@cloudoperators/juno-ui-components" +import { DataGridRow, DataGridCell, Pill, Stack } from "@cloudoperators/juno-ui-components" import { SeverityCount } from "../../../common/SeverityCount" import { ServiceType } from "../../../types" @@ -38,10 +38,9 @@ type ServiceDataRowProps = { item: ServiceType selected: boolean onItemClick: () => void - onServiceDetailClick: () => void } -export const ServiceDataRow = ({ item, selected, onItemClick, onServiceDetailClick }: ServiceDataRowProps) => ( +export const ServiceDataRow = ({ item, selected, onItemClick }: ServiceDataRowProps) => ( {item.name} {/* Due to UX designer feedback, when showing counts with severity icons in datagrid cells, @@ -64,8 +63,5 @@ export const ServiceDataRow = ({ item, selected, onItemClick, onServiceDetailCli - e.stopPropagation()}> - - ) diff --git a/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/index.tsx b/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/index.tsx index f1c1402d94..09e06a9dda 100644 --- a/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/index.tsx +++ b/apps/heureka/src/components/Services/ServicesList/ServicesDataRows/index.tsx @@ -12,7 +12,7 @@ import { getNormalizedServicesResponse } from "../../utils" import { ApolloQueryResult } from "@apollo/client" import { GetServicesQuery } from "../../../../generated/graphql" -const COLUMN_SPAN = 8 +const COLUMN_SPAN = 7 type ServicesDataRowsProps = { servicesPromise: Promise> @@ -24,19 +24,15 @@ export const ServicesDataRows = ({ servicesPromise }: ServicesDataRowsProps) => const { error, data } = use(servicesPromise) const { services } = getNormalizedServicesResponse(data) - const openServiceOverviewPanel = useCallback((service: ServiceType) => { - navigate({ - to: "/services", - search: (prev) => ({ ...prev, service: service.name }), // copy the existing search params and add new `service` param - }) - }, []) - - const goToServiceDetailsPage = useCallback((service: ServiceType) => { - navigate({ - to: "/services/$service", - params: { service: service.name }, - }) - }, []) + const goToServiceDetailsPage = useCallback( + (serviceItem: ServiceType) => { + navigate({ + to: "/services/$service", + params: { service: serviceItem.name }, + }) + }, + [navigate] + ) if (error) { return Error loading services @@ -51,8 +47,7 @@ export const ServicesDataRows = ({ servicesPromise }: ServicesDataRowsProps) => key={item.id} item={item} selected={item.name === service} - onItemClick={() => openServiceOverviewPanel(item)} - onServiceDetailClick={() => goToServiceDetailsPage(item)} + onItemClick={() => goToServiceDetailsPage(item)} /> )) } diff --git a/apps/heureka/src/components/Services/ServicesList/index.test.tsx b/apps/heureka/src/components/Services/ServicesList/index.test.tsx index 11e2411ee5..81c860e828 100644 --- a/apps/heureka/src/components/Services/ServicesList/index.test.tsx +++ b/apps/heureka/src/components/Services/ServicesList/index.test.tsx @@ -8,10 +8,9 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { createMemoryHistory, createRootRoute, createRoute, Outlet, RouterProvider } from "@tanstack/react-router" import { PortalProvider } from "@cloudoperators/juno-ui-components/index" -import * as fetchImages from "../../../api/fetchImages" import { ServicesList } from "./index" import { getTestRouter } from "../../../mocks/getTestRouter" -import { mockImagesPromise, mockServicesPromise } from "../../../mocks/promises" +import { mockServicesPromise } from "../../../mocks/promises" const renderComponent = () => { const rootRoute = createRootRoute({ @@ -50,20 +49,11 @@ describe("ServicesList", () => { expect(await screen.findByText("alpha")).toBeInTheDocument() }) - it("should render service panel when clicking on a service", async () => { - vitest.spyOn(fetchImages, "fetchImages").mockReturnValue(mockImagesPromise) + it("should navigate to service details page when clicking on a service row", async () => { const { user, router } = await act(() => renderComponent()) - //click on the service await user.click(await screen.findByText("alpha")) - //expect the url to change - expect(router.state.location.href).toBe("/services?service=alpha") - - // expect the service panel to be opened - expect(await screen.findByText("Alpha Overview")).toBeInTheDocument() - - // expect the image version to be displayed - expect(await screen.findByText("repo1")).toBeInTheDocument() + expect(router.state.location.pathname).toBe("/services/alpha") }) }) diff --git a/apps/heureka/src/components/Services/ServicesList/index.tsx b/apps/heureka/src/components/Services/ServicesList/index.tsx index 3968f3fd64..f78a09a08d 100644 --- a/apps/heureka/src/components/Services/ServicesList/index.tsx +++ b/apps/heureka/src/components/Services/ServicesList/index.tsx @@ -15,7 +15,7 @@ import { ErrorBoundary } from "../../common/ErrorBoundary" import { LoadingDataRow } from "../../common/LoadingDataRow" import { getErrorDataRowComponent } from "../../common/getErrorDataRow" -const COLUMN_SPAN = 8 +const COLUMN_SPAN = 7 export const ServicesList = () => { const { queryClient, apiClient } = useRouteContext({ from: "/services/" }) @@ -54,7 +54,6 @@ export const ServicesList = () => { Vulnerability Counts Details - ["edges"]>[number]>["node"] +type VulnerabilityEdgeFromQuery = + NonNullable["vulnerabilities"]>["edges"] extends Array ? E : never + +function extractVulnerabilitiesFromImageNode(imageNode: ImageNodeFromQuery): ImageVulnerability[] { + const edges = imageNode?.vulnerabilities?.edges || [] + return edges + .filter((edge): edge is NonNullable => edge !== null && edge?.node != null) + .map((edge) => { + const node = edge.node + return { + id: node.id || "", + severity: node.severity || "", + name: node.name || "", + earliestTargetRemediationDate: node.earliestTargetRemediationDate || "", + description: node.description || "", + sourceUrl: node.sourceUrl || "", + } + }) +} + export const getNormalizedImageVulnerabilitiesResponse = ( // Apollo Client's use() hook can return DeepPartialObject during loading data: GetImagesQuery | undefined ): NormalizedServiceImageVulnerabilities => { - if (!data?.Images?.edges?.[0]?.node) { + const edges = data?.Images?.edges + if (!edges?.length) { return { vulnerabilities: [], totalImageVulnerabilities: 0, @@ -284,46 +325,34 @@ export const getNormalizedImageVulnerabilitiesResponse = ( } } - const imageNode = data.Images.edges[0].node - const vulnerabilitiesEdges = imageNode.vulnerabilities?.edges || [] - const vulnerabilitiesPageInfo = imageNode.vulnerabilities?.pageInfo - - // Extract the type for vulnerability edge from GetImagesQuery structure - type VulnerabilityEdge = NonNullable< - NonNullable["edges"]>[0]>["node"] - >["vulnerabilities"] extends infer V - ? V extends { edges: Array } - ? E - : never - : never - - const vulnerabilities: ImageVulnerability[] = vulnerabilitiesEdges - .filter((edge): edge is NonNullable => edge !== null && edge.node !== null) - .map((edge) => { - const node = edge.node - return { - id: node.id || "", - severity: node.severity || "", - name: node.name || "", - earliestTargetRemediationDate: node.earliestTargetRemediationDate || "", - description: node.description || "", - sourceUrl: node.sourceUrl || "", - } - }) + // Aggregate vulnerabilities from all image edges (handles vulFilter responses that may return multiple nodes or different shapes) + const allVulnerabilities: ImageVulnerability[] = [] + let vulnerabilitiesPageInfo: NonNullable["vulnerabilities"]>["pageInfo"] = null + let totalImageVulnerabilities = 0 + + for (const edge of edges) { + const node = edge?.node + if (!node) continue + const vulns = extractVulnerabilitiesFromImageNode(node) + allVulnerabilities.push(...vulns) + if (node.vulnerabilityCounts?.total != null) { + totalImageVulnerabilities = Math.max(totalImageVulnerabilities, node.vulnerabilityCounts.total) + } + if (node.vulnerabilities?.pageInfo && !vulnerabilitiesPageInfo) { + vulnerabilitiesPageInfo = node.vulnerabilities.pageInfo + } + } - const totalImageVulnerabilities = imageNode.vulnerabilityCounts?.total ?? 0 - const pages = vulnerabilitiesPageInfo?.pages?.filter((edge): edge is Page => edge !== null) || [] + const pages = vulnerabilitiesPageInfo?.pages?.filter((p): p is Page => p !== null) || [] const pageNumber = vulnerabilitiesPageInfo?.pageNumber || 1 - - // When vulnerabilities.edges is [] or vulnerabilities.pageInfo is null, there are no results - const hasNoResults = vulnerabilities.length === 0 || !vulnerabilitiesPageInfo + const hasNoResults = allVulnerabilities.length === 0 || !vulnerabilitiesPageInfo return { - vulnerabilities, + vulnerabilities: allVulnerabilities, totalImageVulnerabilities, pages, pageNumber, - totalCount: hasNoResults ? 0 : vulnerabilities.length, + totalCount: hasNoResults ? 0 : allVulnerabilities.length, } } @@ -400,3 +429,205 @@ export const sanitizeFilterSettings = (filters: Filter[], filterSettings: Filter selectedFilters: validFilters, } } + +// Types for ImageVersion details (will be properly typed after codegen) +export type ImageVersionDetails = { + id: string + tag?: string | null + repository?: string | null + version: string + vulnerabilityCounts: IssuesCountsType + occurrences?: ComponentInstance[] + vulnerabilities?: ImageVulnerability[] +} + +// Alias for compatibility with old panel structure +export type ServiceImageVersion = { + id: string + tag?: string | null + repository?: string | null + version: string + issueCounts: IssuesCountsType + componentInstances?: ComponentInstance[] + componentInstancesCount?: number + vulnerabilities?: ImageVulnerability[] +} + +type NormalizedImageVersionDetails = { + imageVersion: ImageVersionDetails | null + totalCount: number + pages: Page[] + pageNumber: number +} + +// Normalization function for GetImageVersions query +export const getNormalizedImageVersionDetailsResponse = ( + data: GetImageVersionsQuery | undefined +): NormalizedImageVersionDetails => { + if (!data?.ImageVersions?.edges?.[0]?.node) { + return { + imageVersion: null, + totalCount: 0, + pages: [], + pageNumber: 1, + } + } + + const imageVersionNode = data.ImageVersions.edges[0].node + const vulnerabilitiesEdges = imageVersionNode.vulnerabilities?.edges || [] + const occurrencesEdges = imageVersionNode.occurences?.edges || [] + const vulnerabilitiesPageInfo = imageVersionNode.vulnerabilities?.pageInfo + + const vulnerabilities: ImageVulnerability[] = vulnerabilitiesEdges + .filter( + (edge: VulnerabilityEdge | null | undefined): edge is VulnerabilityEdge => edge != null && edge.node != null + ) + .map((edge: VulnerabilityEdge) => { + const node = edge.node + return { + id: node.id || "", + severity: node.severity || "", + name: node.name || "", + earliestTargetRemediationDate: node.earliestTargetRemediationDate || "", + description: node.description || "", + sourceUrl: node.sourceUrl || "", + } + }) + + // Helper function to parse ccrn string and extract fields + const parseCcrn = ( + ccrn: string + ): { cluster?: string; namespace?: string; pod?: string; container?: string; region?: string } => { + if (!ccrn) return {} + + const result: { cluster?: string; namespace?: string; pod?: string; container?: string; region?: string } = {} + + // Parse ccrn format: "ccrn: apiVersion=..., kind=container, cluster=..., namespace=..., pod=..., name=..." + const clusterMatch = ccrn.match(/cluster=([^,]+)/) + const namespaceMatch = ccrn.match(/namespace=([^,]+)/) + const podMatch = ccrn.match(/pod=([^,]+)/) + const nameMatch = ccrn.match(/name=([^,]+)/) + + if (clusterMatch) result.cluster = clusterMatch[1].trim() + if (namespaceMatch) result.namespace = namespaceMatch[1].trim() + if (podMatch) result.pod = podMatch[1].trim() + if (nameMatch) result.container = nameMatch[1].trim() + + // Extract region from cluster name if it follows a pattern like s-eu-nl-1 (region would be "eu") + if (result.cluster) { + const regionMatch = result.cluster.match(/^[^-]+-([^-]+)-/) + if (regionMatch) result.region = regionMatch[1] + } + + return result + } + + const occurrences: ComponentInstance[] = occurrencesEdges + .filter( + (edge: ComponentInstanceEdge | null | undefined): edge is ComponentInstanceEdge => + edge != null && edge.node != null + ) + .map((edge: ComponentInstanceEdge) => { + const node = edge.node + const ccrn = node.ccrn || "" + const parsed = parseCcrn(ccrn) + + return { + id: node.id || "", + ccrn: ccrn, + componentVersionId: node.componentVersionId || "", + cluster: parsed.cluster || "", + namespace: parsed.namespace || "", + pod: parsed.pod || "", + container: parsed.container || "", + region: parsed.region || "", + } + }) + + const imageVersion: ImageVersionDetails = { + id: imageVersionNode.id || "", + tag: imageVersionNode.tag || null, + repository: imageVersionNode.repository || null, + version: imageVersionNode.version || "", + vulnerabilityCounts: imageVersionNode.vulnerabilityCounts || { + critical: 0, + high: 0, + medium: 0, + low: 0, + none: 0, + total: 0, + }, + occurrences, + vulnerabilities, + } + + const pages = vulnerabilitiesPageInfo?.pages?.filter((edge: any): edge is Page => edge !== null) || [] + const pageNumber = vulnerabilitiesPageInfo?.pageNumber || 1 + const hasNoResults = vulnerabilities.length === 0 || !vulnerabilitiesPageInfo + + return { + imageVersion, + totalCount: hasNoResults ? 0 : vulnerabilities.length, + pages, + pageNumber, + } +} + +// Normalization function that returns array of image versions (for compatibility with old panel) +export const getNormalizedImageVersionsResponse = ( + data: GetImageVersionsQuery | undefined +): { imageVersions: ServiceImageVersion[] } => { + const normalized = getNormalizedImageVersionDetailsResponse(data) + + if (!normalized.imageVersion) { + return { imageVersions: [] } + } + + const serviceImageVersion: ServiceImageVersion = { + id: normalized.imageVersion.id, + tag: normalized.imageVersion.tag, + repository: normalized.imageVersion.repository, + version: normalized.imageVersion.version, + issueCounts: normalized.imageVersion.vulnerabilityCounts, + componentInstances: normalized.imageVersion.occurrences, + componentInstancesCount: normalized.imageVersion.occurrences?.length || 0, + vulnerabilities: normalized.imageVersion.vulnerabilities, + } + + return { + imageVersions: [serviceImageVersion], + } +} + +export const getNormalizedRemediationsResponse = ( + data: GetRemediationsQuery | undefined +): { remediatedVulnerabilities: RemediatedVulnerability[] } => { + if (!data?.Remediations?.edges) { + return { + remediatedVulnerabilities: [], + } + } + + const remediatedVulnerabilities: RemediatedVulnerability[] = data.Remediations.edges + .filter((edge): edge is NonNullable => edge !== null && edge.node !== null) + .map((edge) => { + const node = edge.node + return { + id: node.vulnerability || "", + remediationId: node.id || "", + type: node.type || null, + description: node.description || null, + service: node.service || null, + image: node.image || null, + vulnerability: node.vulnerability || null, + vulnerabilityId: null, + remediationDate: node.remediationDate != null ? String(node.remediationDate) : null, + remediatedBy: node.remediatedBy ?? null, + expirationDate: node.expirationDate != null ? String(node.expirationDate) : null, + } + }) + + return { + remediatedVulnerabilities, + } +} diff --git a/apps/heureka/src/generated/graphql.ts b/apps/heureka/src/generated/graphql.ts index 6f5e43b44e..fb5466fdb7 100644 --- a/apps/heureka/src/generated/graphql.ts +++ b/apps/heureka/src/generated/graphql.ts @@ -1,5 +1,4 @@ import { gql } from "@apollo/client" -import * as Apollo from "@apollo/client" export type Maybe = T | null export type InputMaybe = Maybe export type Exact = { [K in keyof T]: T[K] } @@ -22,7 +21,6 @@ export type Activity = Node & { __typename?: "Activity" evidences?: Maybe id: Scalars["ID"]["output"] - issueMatchChanges?: Maybe issues?: Maybe metadata?: Maybe services?: Maybe @@ -35,12 +33,6 @@ export type ActivityEvidencesArgs = { first?: InputMaybe } -export type ActivityIssueMatchChangesArgs = { - after?: InputMaybe - filter?: InputMaybe - first?: InputMaybe -} - export type ActivityIssuesArgs = { after?: InputMaybe filter?: InputMaybe @@ -143,7 +135,10 @@ export type Component = Node & { componentVersions?: Maybe id: Scalars["ID"]["output"] metadata?: Maybe + organization?: Maybe + repository?: Maybe type?: Maybe + url?: Maybe } export type ComponentComponentVersionsArgs = { @@ -168,7 +163,8 @@ export type ComponentEdge = Edge & { export type ComponentFilter = { componentCcrn?: InputMaybe>> - componentVersionRepository?: InputMaybe>> + organization?: InputMaybe>> + repository?: InputMaybe>> serviceCcrn?: InputMaybe>> state?: InputMaybe> } @@ -184,7 +180,10 @@ export type ComponentFilterValueComponentCcrnArgs = { export type ComponentInput = { ccrn?: InputMaybe + organization?: InputMaybe + repository?: InputMaybe type?: InputMaybe + url?: InputMaybe } export type ComponentInstance = Node & { @@ -572,6 +571,52 @@ export type ImageFilter = { service?: InputMaybe>> } +export type ImageVersion = Node & { + __typename?: "ImageVersion" + id: Scalars["ID"]["output"] + metadata?: Maybe + occurences?: Maybe + repository?: Maybe + tag?: Maybe + version?: Maybe + vulnerabilities?: Maybe + vulnerabilityCounts?: Maybe +} + +export type ImageVersionOccurencesArgs = { + after?: InputMaybe + first?: InputMaybe +} + +export type ImageVersionVulnerabilitiesArgs = { + after?: InputMaybe + filter?: InputMaybe + first?: InputMaybe +} + +export type ImageVersionConnection = Connection & { + __typename?: "ImageVersionConnection" + counts?: Maybe + edges: Array> + pageInfo?: Maybe + totalCount: Scalars["Int"]["output"] +} + +export type ImageVersionEdge = Edge & { + __typename?: "ImageVersionEdge" + cursor?: Maybe + node: ImageVersion +} + +export type ImageVersionFilter = { + image?: InputMaybe>> + repository?: InputMaybe>> + service?: InputMaybe>> + state?: InputMaybe> + tag?: InputMaybe>> + version?: InputMaybe>> +} + export type Issue = Node & { __typename?: "Issue" activities?: Maybe @@ -659,7 +704,6 @@ export type IssueMatch = Node & { id: Scalars["ID"]["output"] issue: Issue issueId?: Maybe - issueMatchChanges?: Maybe metadata?: Maybe remediationDate?: Maybe severity?: Maybe @@ -681,52 +725,6 @@ export type IssueMatchEvidencesArgs = { first?: InputMaybe } -export type IssueMatchIssueMatchChangesArgs = { - after?: InputMaybe - filter?: InputMaybe - first?: InputMaybe -} - -export type IssueMatchChange = Node & { - __typename?: "IssueMatchChange" - action?: Maybe - activity: Activity - activityId?: Maybe - id: Scalars["ID"]["output"] - issueMatch: IssueMatch - issueMatchId?: Maybe - metadata?: Maybe -} - -export enum IssueMatchChangeActions { - Add = "add", - Remove = "remove", -} - -export type IssueMatchChangeConnection = Connection & { - __typename?: "IssueMatchChangeConnection" - edges?: Maybe>> - pageInfo?: Maybe - totalCount: Scalars["Int"]["output"] -} - -export type IssueMatchChangeEdge = Edge & { - __typename?: "IssueMatchChangeEdge" - cursor?: Maybe - node: IssueMatchChange -} - -export type IssueMatchChangeFilter = { - action?: InputMaybe>> - state?: InputMaybe> -} - -export type IssueMatchChangeInput = { - action?: InputMaybe - activityId?: InputMaybe - issueMatchId?: InputMaybe -} - export type IssueMatchConnection = Connection & { __typename?: "IssueMatchConnection" edges?: Maybe>> @@ -965,7 +963,6 @@ export type Mutation = { createEvidence: Evidence createIssue: Issue createIssueMatch: IssueMatch - createIssueMatchChange: IssueMatchChange createIssueRepository: IssueRepository createIssueVariant: IssueVariant createRemediation: Remediation @@ -980,7 +977,6 @@ export type Mutation = { deleteEvidence: Scalars["String"]["output"] deleteIssue: Scalars["String"]["output"] deleteIssueMatch: Scalars["String"]["output"] - deleteIssueMatchChange: Scalars["String"]["output"] deleteIssueRepository: Scalars["String"]["output"] deleteIssueVariant: Scalars["String"]["output"] deleteRemediation: Scalars["String"]["output"] @@ -1003,7 +999,6 @@ export type Mutation = { updateEvidence: Evidence updateIssue: Issue updateIssueMatch: IssueMatch - updateIssueMatchChange: IssueMatchChange updateIssueRepository: IssueRepository updateIssueVariant: IssueVariant updateRemediation: Remediation @@ -1085,10 +1080,6 @@ export type MutationCreateIssueMatchArgs = { input: IssueMatchInput } -export type MutationCreateIssueMatchChangeArgs = { - input: IssueMatchChangeInput -} - export type MutationCreateIssueRepositoryArgs = { input: IssueRepositoryInput } @@ -1145,10 +1136,6 @@ export type MutationDeleteIssueMatchArgs = { id: Scalars["ID"]["input"] } -export type MutationDeleteIssueMatchChangeArgs = { - id: Scalars["ID"]["input"] -} - export type MutationDeleteIssueRepositoryArgs = { id: Scalars["ID"]["input"] } @@ -1253,11 +1240,6 @@ export type MutationUpdateIssueMatchArgs = { input: IssueMatchInput } -export type MutationUpdateIssueMatchChangeArgs = { - id: Scalars["ID"]["input"] - input: IssueMatchChangeInput -} - export type MutationUpdateIssueRepositoryArgs = { id: Scalars["ID"]["input"] input: IssueRepositoryInput @@ -1315,6 +1297,38 @@ export type PageInfo = { pages?: Maybe>> } +export type Patch = Node & { + __typename?: "Patch" + componentVersionId?: Maybe + componentVersionName?: Maybe + id: Scalars["ID"]["output"] + metadata?: Maybe + serviceId?: Maybe + serviceName?: Maybe +} + +export type PatchConnection = Connection & { + __typename?: "PatchConnection" + edges?: Maybe>> + pageInfo?: Maybe + totalCount: Scalars["Int"]["output"] +} + +export type PatchEdge = Edge & { + __typename?: "PatchEdge" + cursor?: Maybe + node: Patch +} + +export type PatchFilter = { + componentVersionId?: InputMaybe>> + componentVersionName?: InputMaybe>> + id?: InputMaybe>> + serviceId?: InputMaybe>> + serviceName?: InputMaybe>> + state?: InputMaybe> +} + export type Query = { __typename?: "Query" Activities?: Maybe @@ -1324,14 +1338,15 @@ export type Query = { ComponentVersions?: Maybe Components?: Maybe Evidences?: Maybe + ImageVersions?: Maybe Images?: Maybe IssueCounts?: Maybe - IssueMatchChanges?: Maybe IssueMatchFilterValues?: Maybe IssueMatches?: Maybe IssueRepositories?: Maybe IssueVariants?: Maybe Issues?: Maybe + Patches?: Maybe Remediations?: Maybe ScannerRunTagFilterValues?: Maybe>> ScannerRuns?: Maybe @@ -1375,6 +1390,12 @@ export type QueryEvidencesArgs = { first?: InputMaybe } +export type QueryImageVersionsArgs = { + after?: InputMaybe + filter?: InputMaybe + first?: InputMaybe +} + export type QueryImagesArgs = { after?: InputMaybe filter?: InputMaybe @@ -1385,12 +1406,6 @@ export type QueryIssueCountsArgs = { filter?: InputMaybe } -export type QueryIssueMatchChangesArgs = { - after?: InputMaybe - filter?: InputMaybe - first?: InputMaybe -} - export type QueryIssueMatchesArgs = { after?: InputMaybe filter?: InputMaybe @@ -1417,10 +1432,17 @@ export type QueryIssuesArgs = { orderBy?: InputMaybe>> } +export type QueryPatchesArgs = { + after?: InputMaybe + filter?: InputMaybe + first?: InputMaybe +} + export type QueryRemediationsArgs = { after?: InputMaybe filter?: InputMaybe first?: InputMaybe + orderBy?: InputMaybe>> } export type QueryScannerRunsArgs = { @@ -1467,6 +1489,7 @@ export type Remediation = Node & { remediationDate?: Maybe service?: Maybe serviceId?: Maybe + severity?: Maybe type?: Maybe vulnerability?: Maybe vulnerabilityId?: Maybe @@ -1487,7 +1510,9 @@ export type RemediationEdge = Edge & { export type RemediationFilter = { image?: InputMaybe>> + search?: InputMaybe>> service?: InputMaybe>> + severity?: InputMaybe>> state?: InputMaybe> type?: InputMaybe>> vulnerability?: InputMaybe>> @@ -1500,12 +1525,26 @@ export type RemediationInput = { remediatedBy?: InputMaybe remediationDate?: InputMaybe service?: InputMaybe + severity?: InputMaybe type?: InputMaybe vulnerability?: InputMaybe } +export type RemediationOrderBy = { + by?: InputMaybe + direction?: InputMaybe +} + +export enum RemediationOrderByField { + Severity = "severity", + Vulnerability = "vulnerability", +} + export enum RemediationTypeValues { FalsePositive = "false_positive", + Mitigation = "mitigation", + Rescore = "rescore", + RiskAccepted = "risk_accepted", } export type ScannerRun = Node & { @@ -1600,6 +1639,7 @@ export type ServiceRemediationsArgs = { after?: InputMaybe filter?: InputMaybe first?: InputMaybe + orderBy?: InputMaybe>> } export type ServiceSupportGroupsArgs = { @@ -1887,6 +1927,7 @@ export type VulnerabilityFilter = { search?: InputMaybe>> service?: InputMaybe>> severity?: InputMaybe>> + status?: InputMaybe supportGroup?: InputMaybe>> } @@ -1897,6 +1938,162 @@ export type VulnerabilityFilterValue = { supportGroup?: Maybe } +export enum VulnerabilityStatus { + All = "all", + Open = "open", + Remediated = "remediated", +} + +export type CreateRemediationMutationVariables = Exact<{ + input: RemediationInput +}> + +export type CreateRemediationMutation = { + __typename?: "Mutation" + createRemediation: { + __typename?: "Remediation" + id: string + description?: string | null + expirationDate?: any | null + image?: string | null + imageId?: string | null + remediatedBy?: string | null + remediationDate?: any | null + service?: string | null + serviceId?: string | null + severity?: SeverityValues | null + type?: RemediationTypeValues | null + vulnerability?: string | null + vulnerabilityId?: string | null + } +} + +export type DeleteRemediationMutationVariables = Exact<{ + id: Scalars["ID"]["input"] +}> + +export type DeleteRemediationMutation = { __typename?: "Mutation"; deleteRemediation: string } + +export type GetRemediationsQueryVariables = Exact<{ + filter?: InputMaybe +}> + +export type GetRemediationsQuery = { + __typename?: "Query" + Remediations?: { + __typename?: "RemediationConnection" + totalCount: number + edges?: Array<{ + __typename?: "RemediationEdge" + node: { + __typename?: "Remediation" + id: string + type?: RemediationTypeValues | null + description?: string | null + service?: string | null + image?: string | null + vulnerability?: string | null + expirationDate?: any | null + remediationDate?: any | null + remediatedBy?: string | null + } + } | null> | null + } | null +} + +export type GetImageVersionsQueryVariables = Exact<{ + filter?: InputMaybe + first?: InputMaybe + after?: InputMaybe + firstVulnerabilities?: InputMaybe + afterVulnerabilities?: InputMaybe + firstOccurences?: InputMaybe + afterOccurences?: InputMaybe +}> + +export type GetImageVersionsQuery = { + __typename?: "Query" + ImageVersions?: { + __typename?: "ImageVersionConnection" + totalCount: number + counts?: { + __typename?: "SeverityCounts" + critical: number + high: number + medium: number + low: number + none: number + total: number + } | null + edges: Array<{ + __typename?: "ImageVersionEdge" + node: { + __typename?: "ImageVersion" + id: string + tag?: string | null + repository?: string | null + version?: string | null + vulnerabilityCounts?: { + __typename?: "SeverityCounts" + critical: number + high: number + medium: number + low: number + none: number + total: number + } | null + occurences?: { + __typename?: "ComponentInstanceConnection" + edges: Array<{ + __typename?: "ComponentInstanceEdge" + node: { + __typename?: "ComponentInstance" + id: string + ccrn?: string | null + componentVersionId?: string | null + } + } | null> + } | null + vulnerabilities?: { + __typename?: "VulnerabilityConnection" + edges: Array<{ + __typename?: "VulnerabilityEdge" + node: { + __typename?: "Vulnerability" + id: string + severity?: SeverityValues | null + name?: string | null + sourceUrl?: string | null + earliestTargetRemediationDate?: any | null + description?: string | null + } + } | null> + pageInfo?: { + __typename?: "PageInfo" + pageNumber?: number | null + pages?: Array<{ __typename?: "Page"; after?: string | null; pageNumber?: number | null } | null> | null + } | null + } | null + } + } | null> + pageInfo?: { + __typename?: "PageInfo" + hasNextPage?: boolean | null + hasPreviousPage?: boolean | null + isValidPage?: boolean | null + pageNumber?: number | null + nextPageAfter?: string | null + pages?: Array<{ + __typename?: "Page" + after?: string | null + isCurrent?: boolean | null + pageNumber?: number | null + pageCount?: number | null + } | null> | null + } | null + } | null +} + export type GetImagesQueryVariables = Exact<{ imgFilter?: InputMaybe vulFilter?: InputMaybe @@ -2146,6 +2343,130 @@ export type GetVulnerabilityFiltersQuery = { } | null } +export const CreateRemediationDocument = gql` + mutation CreateRemediation($input: RemediationInput!) { + createRemediation(input: $input) { + id + description + expirationDate + image + imageId + remediatedBy + remediationDate + service + serviceId + severity + type + vulnerability + vulnerabilityId + } + } +` +export const DeleteRemediationDocument = gql` + mutation DeleteRemediation($id: ID!) { + deleteRemediation(id: $id) + } +` +export const GetRemediationsDocument = gql` + query GetRemediations($filter: RemediationFilter) { + Remediations(filter: $filter) { + edges { + node { + id + type + description + service + image + vulnerability + expirationDate + remediationDate + remediatedBy + } + } + totalCount + } + } +` +export const GetImageVersionsDocument = gql` + query GetImageVersions( + $filter: ImageVersionFilter + $first: Int + $after: String + $firstVulnerabilities: Int + $afterVulnerabilities: String + $firstOccurences: Int + $afterOccurences: String + ) { + ImageVersions(first: $first, after: $after, filter: $filter) { + counts { + critical + high + medium + low + none + total + } + edges { + node { + id + tag + repository + version + vulnerabilityCounts { + critical + high + medium + low + none + total + } + occurences(first: $firstOccurences, after: $afterOccurences) { + edges { + node { + id + ccrn + componentVersionId + } + } + } + vulnerabilities(first: $firstVulnerabilities, after: $afterVulnerabilities) { + edges { + node { + id + severity + name + sourceUrl + earliestTargetRemediationDate + description + } + } + pageInfo { + pageNumber + pages { + after + pageNumber + } + } + } + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + isValidPage + pageNumber + nextPageAfter + pages { + after + isCurrent + pageNumber + pageCount + } + } + } + } +` export const GetImagesDocument = gql` query GetImages( $imgFilter: ImageFilter @@ -2225,7 +2546,6 @@ export const GetImagesDocument = gql` } } ` -export type GetImagesQueryResult = Apollo.ApolloQueryResult export const GetServiceFiltersDocument = gql` query GetServiceFilters { ServiceFilterValues { @@ -2242,7 +2562,6 @@ export const GetServiceFiltersDocument = gql` } } ` -export type GetServiceFiltersQueryResult = Apollo.ApolloQueryResult export const GetServicesDocument = gql` query GetServices($filter: ServiceFilter, $first: Int, $after: String, $orderBy: [ServiceOrderBy]) { Services(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { @@ -2295,7 +2614,6 @@ export const GetServicesDocument = gql` } } ` -export type GetServicesQueryResult = Apollo.ApolloQueryResult export const GetVulnerabilitiesDocument = gql` query GetVulnerabilities( $filter: VulnerabilityFilter @@ -2354,7 +2672,6 @@ export const GetVulnerabilitiesDocument = gql` } } ` -export type GetVulnerabilitiesQueryResult = Apollo.ApolloQueryResult
Image version not found: {selectedImageVersion}
Available versions: {imageVersions.map((v) => v.version).join(", ") || "none"}
Data: {JSON.stringify(data, null, 2)}