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 ( + + +