+ >
+ )
+}
+
+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..e7717f1259
--- /dev/null
+++ b/apps/heureka/src/components/Service/ImageVersionDetails/index.tsx
@@ -0,0 +1,103 @@
+/*
+ * 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)
+ // Since we're querying for a specific version, we should get exactly one result
+ 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/utils.ts b/apps/heureka/src/components/Services/utils.ts
index 10a32d0d65..d866f15cae 100644
--- a/apps/heureka/src/components/Services/utils.ts
+++ b/apps/heureka/src/components/Services/utils.ts
@@ -13,6 +13,7 @@ import {
ServiceFilter,
GetServiceFiltersQuery,
GetImagesQuery,
+ GetRemediationsQuery,
} from "../../generated/graphql"
import { Filter, FilterSettings, SelectedFilter, ServiceFilterReduced } from "../common/Filters/types"
import { ServiceType } from "../types"
@@ -262,6 +263,20 @@ export type ImageVulnerability = {
sourceUrl: string
}
+export type RemediatedVulnerability = {
+ id: string
+ remediationId: string
+ type: string | null
+ description: string | null
+ service: string | null
+ image: string | null
+ vulnerability: string | null
+ vulnerabilityId: string | null
+ remediationDate: string | null
+ remediatedBy: string | null
+ expirationDate: string | null
+}
+
type NormalizedServiceImageVulnerabilities = {
vulnerabilities: ImageVulnerability[]
totalImageVulnerabilities: number
@@ -400,3 +415,200 @@ 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: any // Will be GetImageVersionsQuery after codegen
+): 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: any) => edge !== null && edge.node !== null)
+ .map((edge: any) => {
+ 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: any) => edge !== null && edge.node !== null)
+ .map((edge: any) => {
+ 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: any // Will be GetImageVersionsQuery after codegen
+): { 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: null,
+ remediatedBy: null,
+ expirationDate: null,
+ }
+ })
+
+ return {
+ remediatedVulnerabilities,
+ }
+}
diff --git a/apps/heureka/src/generated/graphql.ts b/apps/heureka/src/generated/graphql.ts
index 6f5e43b44e..7f7fa6092e 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] }
@@ -143,7 +142,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 +170,8 @@ export type ComponentEdge = Edge & {
export type ComponentFilter = {
componentCcrn?: InputMaybe>>
- componentVersionRepository?: InputMaybe>>
+ organization?: InputMaybe>>
+ repository?: InputMaybe>>
serviceCcrn?: InputMaybe>>
state?: InputMaybe>
}
@@ -184,7 +187,10 @@ export type ComponentFilterValueComponentCcrnArgs = {
export type ComponentInput = {
ccrn?: InputMaybe
+ organization?: InputMaybe
+ repository?: InputMaybe
type?: InputMaybe
+ url?: InputMaybe
}
export type ComponentInstance = Node & {
@@ -572,6 +578,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
@@ -1315,6 +1367,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,6 +1408,7 @@ export type Query = {
ComponentVersions?: Maybe
Components?: Maybe
Evidences?: Maybe
+ ImageVersions?: Maybe
Images?: Maybe
IssueCounts?: Maybe
IssueMatchChanges?: Maybe
@@ -1332,6 +1417,7 @@ export type Query = {
IssueRepositories?: Maybe
IssueVariants?: Maybe
Issues?: Maybe
+ Patches?: Maybe
Remediations?: Maybe
ScannerRunTagFilterValues?: Maybe>>
ScannerRuns?: Maybe
@@ -1375,6 +1461,12 @@ export type QueryEvidencesArgs = {
first?: InputMaybe
}
+export type QueryImageVersionsArgs = {
+ after?: InputMaybe
+ filter?: InputMaybe
+ first?: InputMaybe
+}
+
export type QueryImagesArgs = {
after?: InputMaybe
filter?: InputMaybe
@@ -1417,6 +1509,12 @@ export type QueryIssuesArgs = {
orderBy?: InputMaybe>>
}
+export type QueryPatchesArgs = {
+ after?: InputMaybe
+ filter?: InputMaybe
+ first?: InputMaybe
+}
+
export type QueryRemediationsArgs = {
after?: InputMaybe
filter?: InputMaybe
@@ -1897,6 +1995,152 @@ export type VulnerabilityFilterValue = {
supportGroup?: Maybe
}
+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
+ 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
+ }
+ } | 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 +2390,126 @@ export type GetVulnerabilityFiltersQuery = {
} | null
}
+export const CreateRemediationDocument = gql`
+ mutation CreateRemediation($input: RemediationInput!) {
+ createRemediation(input: $input) {
+ id
+ description
+ expirationDate
+ image
+ imageId
+ remediatedBy
+ remediationDate
+ service
+ serviceId
+ 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
+ }
+ }
+ 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 +2589,6 @@ export const GetImagesDocument = gql`
}
}
`
-export type GetImagesQueryResult = Apollo.ApolloQueryResult
export const GetServiceFiltersDocument = gql`
query GetServiceFilters {
ServiceFilterValues {
@@ -2242,7 +2605,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 +2657,6 @@ export const GetServicesDocument = gql`
}
}
`
-export type GetServicesQueryResult = Apollo.ApolloQueryResult
export const GetVulnerabilitiesDocument = gql`
query GetVulnerabilities(
$filter: VulnerabilityFilter
@@ -2354,7 +2715,6 @@ export const GetVulnerabilitiesDocument = gql`
}
}
`
-export type GetVulnerabilitiesQueryResult = Apollo.ApolloQueryResult
export const GetVulnerabilityFiltersDocument = gql`
query GetVulnerabilityFilters {
VulnerabilityFilterValues {
@@ -2371,4 +2731,3 @@ export const GetVulnerabilityFiltersDocument = gql`
}
}
`
-export type GetVulnerabilityFiltersQueryResult = Apollo.ApolloQueryResult
diff --git a/apps/heureka/src/graphql/Remediations/createRemediation.graphql b/apps/heureka/src/graphql/Remediations/createRemediation.graphql
new file mode 100644
index 0000000000..ae5adcd029
--- /dev/null
+++ b/apps/heureka/src/graphql/Remediations/createRemediation.graphql
@@ -0,0 +1,21 @@
+#SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
+#SPDX-License-Identifier: Apache-2.0
+
+mutation CreateRemediation($input: RemediationInput!) {
+ createRemediation(input: $input) {
+ id
+ description
+ expirationDate
+ image
+ imageId
+ remediatedBy
+ remediationDate
+ service
+ serviceId
+ type
+ vulnerability
+ vulnerabilityId
+ }
+}
+
+
diff --git a/apps/heureka/src/graphql/Remediations/deleteRemediation.graphql b/apps/heureka/src/graphql/Remediations/deleteRemediation.graphql
new file mode 100644
index 0000000000..78ea7756a2
--- /dev/null
+++ b/apps/heureka/src/graphql/Remediations/deleteRemediation.graphql
@@ -0,0 +1,7 @@
+#SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
+#SPDX-License-Identifier: Apache-2.0
+
+mutation DeleteRemediation($id: ID!) {
+ deleteRemediation(id: $id)
+}
+
diff --git a/apps/heureka/src/graphql/Remediations/getRemediations.graphql b/apps/heureka/src/graphql/Remediations/getRemediations.graphql
new file mode 100644
index 0000000000..793a03d413
--- /dev/null
+++ b/apps/heureka/src/graphql/Remediations/getRemediations.graphql
@@ -0,0 +1,19 @@
+#SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
+#SPDX-License-Identifier: Apache-2.0
+
+query GetRemediations($filter: RemediationFilter) {
+ Remediations(filter: $filter) {
+ edges {
+ node {
+ id
+ type
+ description
+ service
+ image
+ vulnerability
+ }
+ }
+ totalCount
+ }
+}
+
diff --git a/apps/heureka/src/graphql/Services/getImageVersions.graphql b/apps/heureka/src/graphql/Services/getImageVersions.graphql
new file mode 100644
index 0000000000..bad1a6dc25
--- /dev/null
+++ b/apps/heureka/src/graphql/Services/getImageVersions.graphql
@@ -0,0 +1,97 @@
+#SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
+#SPDX-License-Identifier: Apache-2.0
+
+query GetImageVersions(
+ $filter: ImageVersionFilter
+ $first: Int
+ $after: String
+ $firstVulnerabilities: Int
+ $afterVulnerabilities: String
+ $firstOccurences: Int
+ $afterOccurences: String
+) {
+ # This query is designed for listing the ImageVersions of a Service
+ # - It's ordered by Vulnerability Count per default, then by Repository
+ # - Default page size is 10, but can be overridden by the $first parameter
+ ImageVersions(first: $first, after: $after, filter: $filter) {
+ # Counts will result in a subquery. Request only if needed.
+ # Counts number of Vulnerabilities per severity
+ counts{
+ critical
+ high
+ medium
+ low
+ none
+ total
+ }
+ edges{
+ node{
+ id
+ tag
+ repository
+ version
+ # Counts will result in a subquery. Request only if needed.
+ # Counts number of Vulnerabilities per severity
+ vulnerabilityCounts{
+ critical
+ high
+ medium
+ low
+ none
+ total
+ }
+ # Default page size is 10, but can be overridden by the $first parameter
+ occurences(first: $firstOccurences, after: $afterOccurences){
+ edges{
+ node{
+ id
+ ccrn
+ componentVersionId
+ }
+ }
+ }
+ # - Only returns Issues of type Vulnerability
+ # - Only returns Issues with at least one IssueMatch with status "new"
+ # - It's ordered by severity per default
+ # - Default page size is 10, but can be overridden by the $first parameter
+ vulnerabilities(first: $firstVulnerabilities, after: $afterVulnerabilities){
+ edges{
+ node{
+ id
+ severity
+ name
+ sourceUrl
+ earliestTargetRemediationDate
+ description
+ }
+ }
+ # Requesting pageInfo will result in a subquery. Request only if needed.
+ pageInfo{
+ pageNumber
+ pages{
+ after
+ pageNumber
+ }
+ }
+ }
+ }
+ }
+ # Total count will result in a subquery. Request only if needed.
+ totalCount
+ # Requesting pageInfo will result in a subquery. Request only if needed.
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ isValidPage
+ pageNumber
+ nextPageAfter
+ pages {
+ after
+ isCurrent
+ pageNumber
+ pageCount
+ }
+ }
+ }
+}
+
diff --git a/apps/heureka/src/routeTree.gen.ts b/apps/heureka/src/routeTree.gen.ts
index af6de7f0ac..2ff0e24d50 100644
--- a/apps/heureka/src/routeTree.gen.ts
+++ b/apps/heureka/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as VulnerabilitiesIndexRouteImport } from './routes/vulnerabiliti
import { Route as ServicesIndexRouteImport } from './routes/services/index'
import { Route as ServicesServiceRouteImport } from './routes/services/$service'
import { Route as ServicesServiceImagesImageRouteImport } from './routes/services/$service/images/$image'
+import { Route as ServicesServiceImagesImageVersionsVersionRouteImport } from './routes/services/$service/images/$image/versions/$version'
const VulnerabilitiesRouteRoute = VulnerabilitiesRouteRouteImport.update({
id: '/vulnerabilities',
@@ -53,6 +54,12 @@ const ServicesServiceImagesImageRoute =
path: '/images/$image',
getParentRoute: () => ServicesServiceRoute,
} as any)
+const ServicesServiceImagesImageVersionsVersionRoute =
+ ServicesServiceImagesImageVersionsVersionRouteImport.update({
+ id: '/versions/$version',
+ path: '/versions/$version',
+ getParentRoute: () => ServicesServiceImagesImageRoute,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -61,14 +68,16 @@ export interface FileRoutesByFullPath {
'/services/$service': typeof ServicesServiceRouteWithChildren
'/services/': typeof ServicesIndexRoute
'/vulnerabilities/': typeof VulnerabilitiesIndexRoute
- '/services/$service/images/$image': typeof ServicesServiceImagesImageRoute
+ '/services/$service/images/$image': typeof ServicesServiceImagesImageRouteWithChildren
+ '/services/$service/images/$image/versions/$version': typeof ServicesServiceImagesImageVersionsVersionRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/services/$service': typeof ServicesServiceRouteWithChildren
'/services': typeof ServicesIndexRoute
'/vulnerabilities': typeof VulnerabilitiesIndexRoute
- '/services/$service/images/$image': typeof ServicesServiceImagesImageRoute
+ '/services/$service/images/$image': typeof ServicesServiceImagesImageRouteWithChildren
+ '/services/$service/images/$image/versions/$version': typeof ServicesServiceImagesImageVersionsVersionRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -78,7 +87,8 @@ export interface FileRoutesById {
'/services/$service': typeof ServicesServiceRouteWithChildren
'/services/': typeof ServicesIndexRoute
'/vulnerabilities/': typeof VulnerabilitiesIndexRoute
- '/services/$service/images/$image': typeof ServicesServiceImagesImageRoute
+ '/services/$service/images/$image': typeof ServicesServiceImagesImageRouteWithChildren
+ '/services/$service/images/$image/versions/$version': typeof ServicesServiceImagesImageVersionsVersionRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -90,6 +100,7 @@ export interface FileRouteTypes {
| '/services/'
| '/vulnerabilities/'
| '/services/$service/images/$image'
+ | '/services/$service/images/$image/versions/$version'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -97,6 +108,7 @@ export interface FileRouteTypes {
| '/services'
| '/vulnerabilities'
| '/services/$service/images/$image'
+ | '/services/$service/images/$image/versions/$version'
id:
| '__root__'
| '/'
@@ -106,6 +118,7 @@ export interface FileRouteTypes {
| '/services/'
| '/vulnerabilities/'
| '/services/$service/images/$image'
+ | '/services/$service/images/$image/versions/$version'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -165,15 +178,37 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ServicesServiceImagesImageRouteImport
parentRoute: typeof ServicesServiceRoute
}
+ '/services/$service/images/$image/versions/$version': {
+ id: '/services/$service/images/$image/versions/$version'
+ path: '/versions/$version'
+ fullPath: '/services/$service/images/$image/versions/$version'
+ preLoaderRoute: typeof ServicesServiceImagesImageVersionsVersionRouteImport
+ parentRoute: typeof ServicesServiceImagesImageRoute
+ }
}
}
+interface ServicesServiceImagesImageRouteChildren {
+ ServicesServiceImagesImageVersionsVersionRoute: typeof ServicesServiceImagesImageVersionsVersionRoute
+}
+
+const ServicesServiceImagesImageRouteChildren: ServicesServiceImagesImageRouteChildren =
+ {
+ ServicesServiceImagesImageVersionsVersionRoute:
+ ServicesServiceImagesImageVersionsVersionRoute,
+ }
+
+const ServicesServiceImagesImageRouteWithChildren =
+ ServicesServiceImagesImageRoute._addFileChildren(
+ ServicesServiceImagesImageRouteChildren,
+ )
+
interface ServicesServiceRouteChildren {
- ServicesServiceImagesImageRoute: typeof ServicesServiceImagesImageRoute
+ ServicesServiceImagesImageRoute: typeof ServicesServiceImagesImageRouteWithChildren
}
const ServicesServiceRouteChildren: ServicesServiceRouteChildren = {
- ServicesServiceImagesImageRoute: ServicesServiceImagesImageRoute,
+ ServicesServiceImagesImageRoute: ServicesServiceImagesImageRouteWithChildren,
}
const ServicesServiceRouteWithChildren = ServicesServiceRoute._addFileChildren(
diff --git a/apps/heureka/src/routes/services/$service.tsx b/apps/heureka/src/routes/services/$service.tsx
index 41d107e362..d2ce179f11 100644
--- a/apps/heureka/src/routes/services/$service.tsx
+++ b/apps/heureka/src/routes/services/$service.tsx
@@ -6,17 +6,32 @@
import { createFileRoute } from "@tanstack/react-router"
import { Service } from "../../components/Service"
import { fetchService } from "../../api/fetchService"
+import { NetworkStatus, ObservableQuery } from "@apollo/client"
+import { GetServicesQuery } from "../../generated/graphql"
export const Route = createFileRoute("/services/$service")({
shouldReload: false,
- loader: ({ context, params: { service } }) => {
+ loader: ({ context, params: { service }, location }) => {
const { queryClient, apiClient } = context
- // create a promise to fetch the service
- const servicePromise = fetchService({
- queryClient,
- apiClient,
- service,
- })
+
+ // Skip fetching service if we're on the image details page or version details page
+ // Check if the pathname includes "/images/" which indicates we're on an image details or version details route
+ const isOnImageOrVersionDetailsPage = location.pathname.includes("/images/")
+
+ // Return a no-op promise if on image or version details page to avoid unnecessary fetch
+ // ServiceDetails component won't render on these pages, so this promise won't be used
+ const servicePromise: Promise> = isOnImageOrVersionDetailsPage
+ ? Promise.resolve({
+ data: { Services: { edges: [], totalCount: 0, pageInfo: null } },
+ loading: false,
+ networkStatus: NetworkStatus.ready,
+ partial: false,
+ } as unknown as ObservableQuery.Result)
+ : fetchService({
+ queryClient,
+ apiClient,
+ service,
+ })
return {
servicePromise,
diff --git a/apps/heureka/src/routes/services/$service/images/$image.tsx b/apps/heureka/src/routes/services/$service/images/$image.tsx
index 12f2ccd172..f970c068e6 100644
--- a/apps/heureka/src/routes/services/$service/images/$image.tsx
+++ b/apps/heureka/src/routes/services/$service/images/$image.tsx
@@ -4,7 +4,7 @@
*/
import React, { Suspense } from "react"
-import { createFileRoute } from "@tanstack/react-router"
+import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"
import { ImageDetails } from "../../../../components/Service/ImageDetails"
import { fetchImages } from "../../../../api/fetchImages"
import { Spinner } from "@cloudoperators/juno-ui-components"
@@ -22,6 +22,7 @@ export const Route = createFileRoute("/services/$service/images/$image")({
service: [service],
repository: [image],
},
+ firstVersions: 100, // Fetch versions for the image
})
return {
@@ -37,7 +38,15 @@ export const Route = createFileRoute("/services/$service/images/$image")({
function ImageDetailsPage() {
const { imagesPromise } = Route.useLoaderData()
const { service, image } = Route.useParams()
+ const matchRoute = useMatchRoute()
+ const isOnVersionDetailsPage = matchRoute({ to: "/services/$service/images/$image/versions/$version" })
+ // If we're on a child route (version details), just render the outlet
+ if (isOnVersionDetailsPage) {
+ return
+ }
+
+ // Otherwise, render the image details
return (
}>
diff --git a/apps/heureka/src/routes/services/$service/images/$image/versions/$version.tsx b/apps/heureka/src/routes/services/$service/images/$image/versions/$version.tsx
new file mode 100644
index 0000000000..6c7c9ea742
--- /dev/null
+++ b/apps/heureka/src/routes/services/$service/images/$image/versions/$version.tsx
@@ -0,0 +1,57 @@
+/*
+ * 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 { createFileRoute } from "@tanstack/react-router"
+import { ImageVersionDetails } from "../../../../../../components/Service/ImageVersionDetails"
+import { fetchImageVersions } from "../../../../../../api/fetchImageVersions"
+import { Spinner } from "@cloudoperators/juno-ui-components"
+import { ErrorBoundary } from "../../../../../../components/common/ErrorBoundary"
+import { getShortSha256 } from "../../../../../../utils"
+
+export const Route = createFileRoute("/services/$service/images/$image/versions/$version")({
+ shouldReload: false,
+ loader: ({ context, params: { service, image, version } }) => {
+ const { queryClient, apiClient } = context
+ // create a promise to fetch the image versions for this service, image, and version
+ const imageVersionsPromise = fetchImageVersions({
+ queryClient,
+ apiClient,
+ filter: {
+ service: [service],
+ repository: [image],
+ version: [version],
+ },
+ firstVulnerabilities: 20,
+ firstOccurences: 100,
+ })
+
+ return {
+ imageVersionsPromise,
+ crumb: {
+ label: getShortSha256(version),
+ },
+ }
+ },
+ component: ImageVersionDetailsPage,
+})
+
+function ImageVersionDetailsPage() {
+ const { imageVersionsPromise } = Route.useLoaderData()
+ const { service, image, version } = Route.useParams()
+
+ return (
+
+ }>
+
+
+
+ )
+}
diff --git a/apps/heureka/src/utils.ts b/apps/heureka/src/utils.ts
index 10ae355c9d..b3ff3c2da4 100644
--- a/apps/heureka/src/utils.ts
+++ b/apps/heureka/src/utils.ts
@@ -94,3 +94,22 @@ export const mapObject = (obj: Record, iteratee: (value: T, key
})
return result
}
+
+/**
+ * Extracts the first 7 characters after "sha256:" from a version string.
+ * If the version doesn't match the pattern, returns the original version.
+ * @param version - The version string (e.g., "sha256:abc123def456...")
+ * @returns The first 7 characters after "sha256:" or the original version
+ */
+export const getShortSha256 = (version: string): string => {
+ if (!version) return version
+
+ // Check if it starts with "sha256:"
+ const sha256Match = version.match(/^sha256:(.{7})/)
+ if (sha256Match) {
+ return sha256Match[1] // Return the first 7 characters after "sha256:"
+ }
+
+ // If it doesn't match the pattern, return as is
+ return version
+}
diff --git a/apps/heureka/turbo.json b/apps/heureka/turbo.json
index ad49bf44e1..980a977a41 100644
--- a/apps/heureka/turbo.json
+++ b/apps/heureka/turbo.json
@@ -2,19 +2,39 @@
"extends": ["//"],
"tasks": {
"dev": {
- "dependsOn": ["@cloudoperators/juno-ui-components#build", "@cloudoperators/juno-url-state-provider#build"]
+ "dependsOn": [
+ "@cloudoperators/juno-ui-components#build",
+ "@cloudoperators/juno-messages-provider#build",
+ "@cloudoperators/juno-url-state-provider#build"
+ ]
},
"test": {
- "dependsOn": ["@cloudoperators/juno-ui-components#build", "@cloudoperators/juno-url-state-provider#build"]
+ "dependsOn": [
+ "@cloudoperators/juno-ui-components#build",
+ "@cloudoperators/juno-messages-provider#build",
+ "@cloudoperators/juno-url-state-provider#build"
+ ]
},
"build": {
- "dependsOn": ["@cloudoperators/juno-ui-components#build", "@cloudoperators/juno-url-state-provider#build"]
+ "dependsOn": [
+ "@cloudoperators/juno-ui-components#build",
+ "@cloudoperators/juno-messages-provider#build",
+ "@cloudoperators/juno-url-state-provider#build"
+ ]
},
"typecheck": {
- "dependsOn": ["@cloudoperators/juno-ui-components#build", "@cloudoperators/juno-url-state-provider#build"]
+ "dependsOn": [
+ "@cloudoperators/juno-ui-components#build",
+ "@cloudoperators/juno-messages-provider#build",
+ "@cloudoperators/juno-url-state-provider#build"
+ ]
},
"build:static": {
- "dependsOn": ["@cloudoperators/juno-ui-components#build", "@cloudoperators/juno-url-state-provider#build"]
+ "dependsOn": [
+ "@cloudoperators/juno-ui-components#build",
+ "@cloudoperators/juno-messages-provider#build",
+ "@cloudoperators/juno-url-state-provider#build"
+ ]
}
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3e99b2828f..fd80170ad0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -409,6 +409,9 @@ importers:
apps/heureka:
dependencies:
+ '@cloudoperators/juno-messages-provider':
+ specifier: workspace:*
+ version: link:../../packages/messages-provider
'@cloudoperators/juno-ui-components':
specifier: workspace:*
version: link:../../packages/ui-components