diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts new file mode 100644 index 00000000000..a6732874db1 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts @@ -0,0 +1,70 @@ +import { findHighlightRanges, formatObjectLogMessage } from './format-object-log-message' + +describe('formatObjectLogMessage', () => { + it('formats a JSON object with two-space indentation', () => { + expect(formatObjectLogMessage('{"message":"Started","context":{"attempt":2},"ports":[80,443]}').message).toBe(`{ + "message": "Started", + "context": { + "attempt": 2 + }, + "ports": [ + 80, + 443 + ] +}`) + }) + + it('preserves numeric literals and duplicate keys', () => { + expect(formatObjectLogMessage('{"id":9007199254740993,"decimal":42.0,"exponent":1e3,"key":1,"key":2}').message) + .toBe(`{ + "id": 9007199254740993, + "decimal": 42.0, + "exponent": 1e3, + "key": 1, + "key": 2 +}`) + }) + + it('preserves whitespace and escaped characters inside strings', () => { + expect(formatObjectLogMessage('{"message":"keep spaces, braces { } and \\"quotes\\""}').message).toBe(`{ + "message": "keep spaces, braces { } and \\"quotes\\"" +}`) + }) + + it('returns the formatted ranges for object keys', () => { + const formattedLogMessage = formatObjectLogMessage('{"message":"Started","context":{"attempt":2}}') + + expect( + formattedLogMessage.keyRanges?.map(({ start, end }) => formattedLogMessage.message.slice(start, end)) + ).toEqual(['"message"', '"context"', '"attempt"']) + }) + + it.each([ + ['invalid JSON', '{"message":"Started"'], + ['an array', '["Started"]'], + ['a string', '"Started"'], + ['a number', '42'], + ['a boolean', 'true'], + ['null', 'null'], + ['plain text', 'Started successfully'], + ])('keeps %s unchanged', (_description, message) => { + expect(formatObjectLogMessage(message)).toEqual({ message }) + }) + + it('maps a raw compact match to its formatted range', () => { + const rawMessage = '{"message":"Started","attempt":2}' + const formattedLogMessage = formatObjectLogMessage(rawMessage) + const ranges = findHighlightRanges(rawMessage, formattedLogMessage, '"message":"Started"') + + expect(ranges).toEqual([{ start: 4, end: 24 }]) + expect(formattedLogMessage.message.slice(ranges[0]?.start, ranges[0]?.end)).toBe('"message": "Started"') + }) + + it('finds case-insensitive matches in plain-text messages', () => { + const rawMessage = 'Started successfully' + + expect(findHighlightRanges(rawMessage, formatObjectLogMessage(rawMessage), 'started')).toEqual([ + { start: 0, end: 7 }, + ]) + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts new file mode 100644 index 00000000000..663a199685c --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts @@ -0,0 +1,147 @@ +export interface HighlightRange { + start: number + end: number +} + +export interface FormattedLogMessage { + message: string + sourceMap?: number[] + keyRanges?: HighlightRange[] +} + +function nextNonWhitespaceCharacter(message: string, start: number) { + for (let index = start; index < message.length; index++) { + if (!/\s/.test(message[index] ?? '')) return message[index] + } + + return undefined +} + +function previousNonWhitespaceCharacter(message: string, start: number) { + for (let index = start; index >= 0; index--) { + if (!/\s/.test(message[index] ?? '')) return message[index] + } + + return undefined +} + +export function formatObjectLogMessage(message: string): FormattedLogMessage { + try { + const parsedMessage: unknown = JSON.parse(message) + + if (typeof parsedMessage !== 'object' || parsedMessage === null || Array.isArray(parsedMessage)) { + return { message } + } + } catch { + return { message } + } + + let formattedMessage = '' + let indentation = 0 + let isInsideString = false + let isEscaped = false + let currentStringStart = 0 + const sourceMap = Array.from({ length: message.length }).fill(-1) + const keyRanges: HighlightRange[] = [] + + const appendSourceCharacter = (character: string, sourceIndex: number) => { + sourceMap[sourceIndex] = formattedMessage.length + formattedMessage += character + } + + const appendNewLine = () => { + formattedMessage += `\n${' '.repeat(indentation)}` + } + + for (let index = 0; index < message.length; index++) { + const character = message[index] ?? '' + + if (isInsideString) { + appendSourceCharacter(character, index) + + if (isEscaped) { + isEscaped = false + } else if (character === '\\') { + isEscaped = true + } else if (character === '"') { + isInsideString = false + if (nextNonWhitespaceCharacter(message, index + 1) === ':') { + keyRanges.push({ start: currentStringStart, end: formattedMessage.length }) + } + } + + continue + } + + if (/\s/.test(character)) continue + + if (character === '"') { + isInsideString = true + currentStringStart = formattedMessage.length + appendSourceCharacter(character, index) + continue + } + + if (character === '{' || character === '[') { + appendSourceCharacter(character, index) + + const closingCharacter = character === '{' ? '}' : ']' + if (nextNonWhitespaceCharacter(message, index + 1) !== closingCharacter) { + indentation++ + appendNewLine() + } + continue + } + + if (character === '}' || character === ']') { + const openingCharacter = character === '}' ? '{' : '[' + if (previousNonWhitespaceCharacter(message, index - 1) !== openingCharacter) { + indentation-- + appendNewLine() + } + appendSourceCharacter(character, index) + continue + } + + appendSourceCharacter(character, index) + + if (character === ',') { + appendNewLine() + } else if (character === ':') { + formattedMessage += ' ' + } + } + + return { message: formattedMessage, sourceMap, keyRanges } +} + +export function findHighlightRanges( + rawMessage: string, + { message, sourceMap }: FormattedLogMessage, + searchTerm: string | null | undefined +): HighlightRange[] { + if (!searchTerm) return [] + + const ranges: HighlightRange[] = [] + const normalizedSearchTerm = searchTerm.toLowerCase() + const searchableMessage = sourceMap ? rawMessage : message + const normalizedMessage = searchableMessage.toLowerCase() + let matchIndex = normalizedMessage.indexOf(normalizedSearchTerm) + + while (matchIndex >= 0) { + if (!sourceMap) { + ranges.push({ start: matchIndex, end: matchIndex + searchTerm.length }) + } else { + const matchEnd = matchIndex + searchTerm.length + const mappedOffsets = sourceMap.slice(matchIndex, matchEnd).filter((offset) => offset >= 0) + + if (mappedOffsets.length > 0) { + ranges.push({ start: mappedOffsets[0] ?? 0, end: (mappedOffsets[mappedOffsets.length - 1] ?? 0) + 1 }) + } + } + + matchIndex = normalizedMessage.indexOf(normalizedSearchTerm, matchIndex + searchTerm.length) + } + + return ranges +} diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx index 32d8327fde6..4f21498936b 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx @@ -67,6 +67,70 @@ describe('RowServiceLogs', () => { expect(screen.getByText('Test log message')).toBeInTheDocument() }) + it('renders JSON object messages with two-space indentation', () => { + const message = '{"message":"Started","context":{"attempt":2},"ports":[80,443]}' + const formattedMessage = `{ + "message": "Started", + "context": { + "attempt": 2 + }, + "ports": [ + 80, + 443 + ] +}` + + const { container } = renderRowServiceLogs({ ...mockLog, message }) + + expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(formattedMessage) + }) + + it('renders JSON object keys with the accent color', () => { + const { container } = renderRowServiceLogs({ + ...mockLog, + message: '{"message":"Started","context":{"attempt":2}}', + }) + + expect(Array.from(container.querySelectorAll('.text-accent1')).map((element) => element.textContent)).toEqual([ + '"message"', + '"context"', + '"attempt"', + ]) + expect(container.querySelector('[data-log-message="true"]')).toHaveTextContent('"message": "Started"') + }) + + it('highlights search text in a formatted JSON object message', () => { + renderRowServiceLogs({ ...mockLog, message: '{"message":"Started"}' }, false, 'Started') + + expect(screen.getByText('Started').closest('mark')).toBeInTheDocument() + expect(screen.getByText('Started').closest('[data-log-message="true"]')).toHaveTextContent( + '{ "message": "Started" }' + ) + }) + + it('highlights a compact raw JSON match after formatting', () => { + const { container } = renderRowServiceLogs( + { ...mockLog, message: '{"message":"Started","attempt":2}' }, + false, + '"message":"Started"' + ) + + expect( + Array.from(container.querySelectorAll('mark')) + .map((element) => element.textContent) + .join('') + ).toBe('"message": "Started"') + }) + + it.each(['["Started"]', '"Started"', '42', 'true', 'null', '{"message":"Started"', 'Started successfully'])( + 'keeps non-object message %s unchanged', + (message) => { + const { container } = renderRowServiceLogs({ ...mockLog, message }) + + expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(message) + } + ) + it('toggles expanded state on click', async () => { const { userEvent } = renderRowServiceLogs() diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx index da90d07cff6..12f82f0612f 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx @@ -1,6 +1,6 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import clsx from 'clsx' -import { type MouseEvent, type ReactNode, useCallback, useState } from 'react' +import { type MouseEvent, type ReactNode, useCallback, useMemo, useState } from 'react' import { type NormalizedServiceLog } from '@qovery/domains/service-logs/data-access' import { type AnyService } from '@qovery/domains/services/data-access' import { type ServiceLogsParams } from '@qovery/shared/router' @@ -25,6 +25,7 @@ import { } from '@qovery/shared/util-js' import { mergeServiceLogsParams } from '../../search-service-logs/search-service-logs-utils' import { useServiceLogsContext } from '../service-logs-context/service-logs-context' +import { type HighlightRange, findHighlightRanges, formatObjectLogMessage } from './format-object-log-message' import './style.scss' const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone @@ -36,11 +37,18 @@ const TRAILING_URL_PUNCTUATION_REGEX = /[),.;!?]+$/ function renderHighlightedText( text: string, - searchTerm: string | null | undefined, + textStart: number, + highlightRanges: HighlightRange[], + keyRanges: HighlightRange[], key: string, renderAnsi = true ): ReactNode { - if (!searchTerm || !text.toLowerCase().includes(searchTerm.toLowerCase())) { + const textEnd = textStart + text.length + const visibleHighlightRanges = highlightRanges.filter(({ start, end }) => start < textEnd && end > textStart) + const visibleKeyRanges = keyRanges.filter(({ start, end }) => start < textEnd && end > textStart) + const ranges = [...visibleHighlightRanges, ...visibleKeyRanges] + + if (ranges.length === 0) { if (!renderAnsi) return text return ( @@ -50,27 +58,68 @@ function renderHighlightedText( ) } - const parts = text.split(new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')) + const boundaries = new Set([0, text.length]) + for (const { start, end } of ranges) { + boundaries.add(Math.max(start, textStart) - textStart) + boundaries.add(Math.min(end, textEnd) - textStart) + } + + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b) + let highlightRangeIndex = 0 + let keyRangeIndex = 0 + const parts = sortedBoundaries.slice(0, -1).map((start, index) => { + const end = sortedBoundaries[index + 1] ?? start + const globalStart = textStart + start + const globalEnd = textStart + end - return parts.map((part, index) => - part.toLowerCase() === searchTerm.toLowerCase() ? ( - - {part} - - ) : renderAnsi ? ( + while ((visibleHighlightRanges[highlightRangeIndex]?.end ?? Infinity) <= globalStart) { + highlightRangeIndex++ + } + while ((visibleKeyRanges[keyRangeIndex]?.end ?? Infinity) <= globalStart) { + keyRangeIndex++ + } + + const highlightRange = visibleHighlightRanges[highlightRangeIndex] + const keyRange = visibleKeyRanges[keyRangeIndex] + + return { + text: text.slice(start, end), + highlighted: Boolean(highlightRange && highlightRange.start < globalEnd), + isKey: Boolean(keyRange && keyRange.start < globalEnd), + } + }) + + return parts.map((part, index) => { + if (part.highlighted) { + return ( + + {part.text} + + ) + } + + if (part.isKey) { + return ( + + {part.text} + + ) + } + + if (!renderAnsi) return part.text + + return ( - {part} + {part.text} - ) : ( - part ) - ) + }) } export interface RowServiceLogsProps { @@ -121,7 +170,8 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se if (!isNginx && !isEnvoy) setIsExpanded(!isExpanded) } - const renderHighlightedMessage = (message: string, searchTerm: string | null | undefined) => { + const renderHighlightedMessage = (message: string, highlightRanges: HighlightRange[]) => { + const keyRanges = formattedLogMessage.keyRanges ?? [] const content: ReactNode[] = [] let currentIndex = 0 @@ -131,7 +181,15 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se const startIndex = match.index ?? 0 if (startIndex > currentIndex) { - content.push(renderHighlightedText(message.slice(currentIndex, startIndex), searchTerm, `text-${currentIndex}`)) + content.push( + renderHighlightedText( + message.slice(currentIndex, startIndex), + currentIndex, + highlightRanges, + keyRanges, + `text-${currentIndex}` + ) + ) } content.push( @@ -143,14 +201,20 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se aria-label={url} className="underline" > - {renderHighlightedText(url, searchTerm, `url-text-${startIndex}`, false)} + {renderHighlightedText(url, startIndex, highlightRanges, keyRanges, `url-text-${startIndex}`, false)} ) const trailingPunctuation = matchedUrl.slice(url.length) if (trailingPunctuation) { content.push( - renderHighlightedText(trailingPunctuation, searchTerm, `trailing-punctuation-${startIndex + url.length}`) + renderHighlightedText( + trailingPunctuation, + startIndex + url.length, + highlightRanges, + keyRanges, + `trailing-punctuation-${startIndex + url.length}` + ) ) } @@ -158,7 +222,15 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se } if (currentIndex < message.length) { - content.push(renderHighlightedText(message.slice(currentIndex), searchTerm, `text-${currentIndex}`)) + content.push( + renderHighlightedText( + message.slice(currentIndex), + currentIndex, + highlightRanges, + keyRanges, + `text-${currentIndex}` + ) + ) } return ( @@ -173,6 +245,11 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se const levelLowercase = log.level?.toLowerCase() const isErrorOrCritical = levelLowercase === 'error' || levelLowercase === 'critical' + const formattedLogMessage = useMemo(() => formatObjectLogMessage(log.message), [log.message]) + const highlightRanges = useMemo( + () => findHighlightRanges(log.message, formattedLogMessage, highlightedText), + [formattedLogMessage, highlightedText, log.message] + ) return ( <> @@ -264,7 +341,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se )} - {renderHighlightedMessage(log.message, highlightedText)} + {renderHighlightedMessage(formattedLogMessage.message, highlightRanges)} {isExpanded && ( diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index dc489961c24..0486c7843c8 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -11,6 +11,7 @@ import { ApplicationDeploymentRestrictionApi, type ApplicationDeploymentRestrictionRequest, type ApplicationEditRequest, + type ApplicationGitRepository, ApplicationMainCallsApi, type ApplicationRequest, ApplicationsApi, @@ -245,13 +246,21 @@ export function isBlueprintService(service: AnyService): service is BlueprintSer return 'blueprint_id' in service && Boolean(service.blueprint_id) } -export function getBlueprintGitRepository(service: BlueprintService) { +// Helm blueprints pull their chart from the manifest's upstream repository, but come from the catalog +export const BLUEPRINT_CATALOG_GIT_REPOSITORY: ApplicationGitRepository = { + provider: 'GITHUB', + owner: 'Qovery', + name: 'Qovery/service-catalog', + url: 'https://github.com/Qovery/service-catalog.git', +} + +export function getBlueprintGitRepository(service: BlueprintService): ApplicationGitRepository | undefined { if (service.serviceType === 'TERRAFORM') { return service.terraform_files_source?.git?.git_repository } - if (service.serviceType === 'HELM' && isHelmGitSource(service.source)) { - return service.source.git?.git_repository + if (service.serviceType === 'HELM') { + return isHelmGitSource(service.source) ? service.source.git?.git_repository : BLUEPRINT_CATALOG_GIT_REPOSITORY } return undefined diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx index 16a4de77e96..8616ac73dca 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.spec.tsx @@ -169,6 +169,32 @@ describe('ServiceVersionCell', () => { expect(screen.queryByText('RC test')).not.toBeInTheDocument() }) + it('shows the service catalog as the target of a helm blueprint, not its chart repository', () => { + jest.mocked(useBlueprintUpdate).mockReturnValue({ + data: { is_up_to_date: true, current_tag: 'HELM/redis/8/1.1.0' }, + isLoading: false, + isError: false, + } as ReturnType) + + renderWithProviders() + + expect(screen.getByRole('link', { name: 'Qovery/service-catalog' })).toHaveAttribute( + 'href', + 'https://github.com/Qovery/service-catalog.git' + ) + expect(screen.queryByText('bitnami')).not.toBeInTheDocument() + expect(screen.getByText('redis')).toBeInTheDocument() + }) + + it('keeps the chart repository of a helm service that no blueprint created', () => { + const { blueprint_id: _, ...helmService } = helmBlueprintService + + renderWithProviders() + + expect(screen.getByText('bitnami')).toBeInTheDocument() + expect(screen.queryByText('Qovery/service-catalog')).not.toBeInTheDocument() + }) + it('shows a helm blueprint chart version read-only when the update check cannot answer', () => { jest.mocked(useBlueprintUpdate).mockReturnValue({ data: undefined, diff --git a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx index 021ec171aff..6ba971dc9a9 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list-cells/service-version-cell.tsx @@ -13,6 +13,7 @@ import { type Helm, type Job, type Terraform, + getBlueprintGitRepository, isBlueprintService, } from '@qovery/domains/services/data-access' import { @@ -269,27 +270,48 @@ export function ServiceVersionCell({ service }: ServiceVersionCellProps) { ) - const helmInfo = (helmRepository?: HelmSourceRepositoryResponse) => - helmRepository && ( + const helmInfo = (helmRepository?: HelmSourceRepositoryResponse) => { + if (!helmRepository) return null + const blueprintRepository = isBlueprintService(service) ? getBlueprintGitRepository(service) : undefined + + return (
e.stopPropagation()}>
- - - - {helmRepository.repository?.name} -
- {helmRepository.repository?.url} + {blueprintRepository ? ( + + + + + {blueprintRepository.name} - } - > - - {helmRepository.repository?.name?.toLowerCase()} - -
-
+ + + ) : ( + + + + {helmRepository.repository?.name} +
+ {helmRepository.repository?.url} +
+ } + > + + {helmRepository.repository?.name?.toLowerCase()} + + + + )} @@ -318,6 +340,7 @@ export function ServiceVersionCell({ service }: ServiceVersionCellProps) { )}
) + } const cell = match({ service }) .with({ service: P.intersection({ serviceType: 'JOB' }, { source: P.when(isJobGitSource) }) }, ({ service }) => { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx index 24904475a25..af41cf0c046 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx @@ -93,6 +93,29 @@ const services = { blueprint_id: 'blueprint-id', tag: 'aws/s3/1.2', }, + 'helm-blueprint-mock': { + id: 'helm-blueprint-mock', + serviceType: 'HELM', + service_type: 'HELM', + name: 'tf-bp-redis', + description: '', + icon_uri: 'app://qovery-console/redis', + environment: { + id: 'environment-id', + }, + source: { + repository: { + repository: { + id: 'repository-id', + name: 'blueprint-redis-01a0d3cb-3b16-7e62-9b66-3462d6f1d59e', + url: 'https://groundhog2k.github.io/helm-charts/', + }, + chart_name: 'redis', + chart_version: '2.4.1', + }, + }, + blueprint_id: 'blueprint-id', + }, 'agentic-workflow-mock': { id: 'agentic-workflow-mock', serviceType: 'AGENTIC_WORKFLOW', @@ -376,6 +399,7 @@ describe('ServiceHeader', () => { | 'job-mock' | 'argocd-mock' | 'terraform-mock' + | 'helm-blueprint-mock' | 'agentic-workflow-mock' ) => renderWithProviders() @@ -473,6 +497,14 @@ describe('ServiceHeader', () => { expect(screen.queryByText('Update available')).not.toBeInTheDocument() }) + it('hides the engine-generated chart repository of a helm blueprint', () => { + renderServiceHeader('helm-blueprint-mock') + + expect(screen.queryByText(/blueprint-redis-/)).not.toBeInTheDocument() + expect(screen.getByText('redis')).toBeInTheDocument() + expect(screen.getByText('2.4.1')).toBeInTheDocument() + }) + it('drops a cached update once the blueprint update check starts failing', () => { mockUseBlueprintUpdate.mockReturnValue({ data: { diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx index 2c4b9cc5dad..6400ef24f6e 100644 --- a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx +++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx @@ -277,7 +277,7 @@ function ServiceHeaderMetadata({ service }: ServiceHeaderMetadataProps) { )} {helmRepository && ( <> - {helmRepository.repository && ( + {helmRepository.repository && !blueprintId && (