diff --git a/.changeset/dull-taxis-walk.md b/.changeset/dull-taxis-walk.md new file mode 100644 index 0000000000..389c6c6937 --- /dev/null +++ b/.changeset/dull-taxis-walk.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": major +--- + +Adds false positive remediation action to image details page with create and delete functionality. Introduces image version details page to display deployment locations for each image version. diff --git a/.gitignore b/.gitignore index 764c6d53cd..c5ca99efce 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ vite.config.*.timestamp-* # pnpm local package store (if configured locally) .pnpm-store/ +# TanStack Router cache directory +.tanstack/ + # act artifacts needed for testing workflows .secrets .env diff --git a/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx b/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx index fdfb985dc5..156e8ca313 100644 --- a/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx +++ b/apps/carbon/src/components/ErrorBoundary/ErrorFallback.tsx @@ -8,7 +8,7 @@ import { FallbackProps } from "react-error-boundary" import { Message } from "@cloudoperators/juno-ui-components" const ErrorFallback = ({ error }: FallbackProps) => ( - + ) export default ErrorFallback diff --git a/apps/heureka/.gitignore b/apps/heureka/.gitignore index 65a986bd60..b24fa6e237 100644 --- a/apps/heureka/.gitignore +++ b/apps/heureka/.gitignore @@ -34,3 +34,6 @@ npm-debug.log* .env.development.local .env.test.local .env.production.local + +# TanStack Router cache directory +.tanstack/ 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/package.json b/apps/heureka/package.json index ba29ee62c8..928f898ee6 100644 --- a/apps/heureka/package.json +++ b/apps/heureka/package.json @@ -28,6 +28,7 @@ "clean:cache": "rm -rf .turbo" }, "dependencies": { + "@cloudoperators/juno-messages-provider": "workspace:*", "@cloudoperators/juno-ui-components": "workspace:*", "@cloudoperators/juno-url-state-provider": "workspace:*", "@tanstack/react-query": "5.90.16", diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index 674dbff54e..89043e2516 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -9,6 +9,7 @@ import { createRouter, RouterProvider, createHashHistory, createBrowserHistory } import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { AppShell, AppShellProvider, PageHeader } from "@cloudoperators/juno-ui-components" import { encodeV2, decodeV2 } from "@cloudoperators/juno-url-state-provider" +import { MessagesProvider } from "@cloudoperators/juno-messages-provider" import styles from "./styles.css?inline" import { ErrorBoundary } from "./components/common/ErrorBoundary" import { getClient } from "./apollo-client" @@ -87,21 +88,23 @@ const App = (props: AppProps) => { return ( - - {/* load styles inside the shadow dom */} - - - }> - - - - - - - - - - + + + {/* load styles inside the shadow dom */} + + + }> + + + + + + + + + + + ) 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..60f868bd84 --- /dev/null +++ b/apps/heureka/src/api/fetchImageVersions.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 { 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, + ] + + // Invalidate cache first to ensure queryFn is always called (forces network request) + // Then use ensureQueryData to maintain promise stability (like other fetch functions) + queryClient.invalidateQueries({ queryKey }) + + return queryClient.ensureQueryData({ + queryKey, + queryFn: () => + apiClient.query({ + query: GetImageVersionsDocument, + variables: { + filter, + first, + after, + firstVulnerabilities, + afterVulnerabilities, + firstOccurences, + afterOccurences, + }, + fetchPolicy: "network-only", // Force network request to always fetch fresh data + }), + }) +} 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/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx new file mode 100644 index 0000000000..f3e4aa5c91 --- /dev/null +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -0,0 +1,119 @@ +/* + * 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 { Modal, ModalFooter, Button, Stack, Textarea } from "@cloudoperators/juno-ui-components" +import { RemediationInput, RemediationTypeValues } from "../../../../generated/graphql" + +type FalsePositiveModalProps = { + open: boolean + onClose: () => void + onConfirm: (input: RemediationInput) => Promise + vulnerability: string + service: string + image: string +} + +const CONFIRM_LABEL = "Mark as False Positive" +const CANCEL_LABEL = "Cancel" + +export const FalsePositiveModal: React.FC = ({ + open, + onClose, + onConfirm, + vulnerability, + service, + image, +}) => { + const [description, setDescription] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [descriptionError, setDescriptionError] = useState("") + + const handleConfirm = async () => { + // Validate description + if (!description.trim()) { + setDescriptionError("Description is required") + return + } + + setDescriptionError("") + setIsSubmitting(true) + try { + const input: RemediationInput = { + type: RemediationTypeValues.FalsePositive, + vulnerability, + service, + image, + description: description.trim(), + } + await onConfirm(input) + setDescription("") + } catch (error) { + console.error("Failed to create remediation:", error) + // Error handling is done in the parent component + } finally { + setIsSubmitting(false) + } + } + + const handleClose = () => { + setDescription("") + setDescriptionError("") + onClose() + } + + const handleDescriptionChange = (e: React.ChangeEvent) => { + setDescription(e.target.value) + // Clear error when user starts typing + if (descriptionError) { + setDescriptionError("") + } + } + + return ( + + +