From ed3bef9a5e137f5c181a6e12a94738d57e290db7 Mon Sep 17 00:00:00 2001 From: Murali Annamneni Date: Sat, 7 Jun 2025 00:07:58 +0000 Subject: [PATCH 1/3] app-catalog: Support serviceproxy and support helm repos in the catalog Signed-off-by: Murali Annamneni --- app-catalog/src/api/catalogs.tsx | 53 ++ app-catalog/src/api/charts.tsx | 96 +++- app-catalog/src/components/charts/Details.tsx | 23 +- .../src/components/charts/EditorDialog.tsx | 60 ++- app-catalog/src/components/charts/List.tsx | 477 ++++++++++++------ .../src/components/releases/EditorDialog.tsx | 7 +- app-catalog/src/helpers/catalog.ts | 89 ++++ app-catalog/src/index.tsx | 120 ++++- 8 files changed, 712 insertions(+), 213 deletions(-) create mode 100644 app-catalog/src/api/catalogs.tsx create mode 100644 app-catalog/src/helpers/catalog.ts diff --git a/app-catalog/src/api/catalogs.tsx b/app-catalog/src/api/catalogs.tsx new file mode 100644 index 000000000..77dc42322 --- /dev/null +++ b/app-catalog/src/api/catalogs.tsx @@ -0,0 +1,53 @@ +import { QueryParameters, request } from '@kinvolk/headlamp-plugin/lib/ApiProxy'; +import { ChartsList, COMMUNITY_REPO, VANILLA_HELM_REPO } from '../components/charts/List'; + +const SERVICE_ENDPOINT = '/api/v1/services'; +const LABEL_CATALOG = 'catalog.ocne.io/is-catalog'; + +declare global { + var CHART_URL_PREFIX: string; + var CHART_PROFILE: string; + var CHART_VALUES_PREFIX: string; + var CATALOG_NAMESPACE: string; + var CATALOG_NAME: string; +} + +// Reset the CHART_URL_PREFIX and CHART_PROFILE, to switch between different catalogs +export function ResetGlobalVars( + metadataName: string, + namespace: string, + prefix: string, + profile: string +) { + globalThis.CATALOG_NAME = metadataName; + globalThis.CATALOG_NAMESPACE = namespace; + globalThis.CHART_URL_PREFIX = prefix; + globalThis.CHART_PROFILE = profile; +} + +// Constants for the supported protocols +export const HELM_PROTOCOL = 'helm'; +export const ARTIFACTHUB_PROTOCOL = 'artifacthub'; + +// Reset the variables and call ChartsList, when the protocol is helm +export function HelmChartList(metadataName: string, namespace: string, chartUrl: string) { + ResetGlobalVars(metadataName, namespace, chartUrl, VANILLA_HELM_REPO); + // Let the default value for CHART_VALUES_PREFIX be "values" + globalThis.CHART_VALUES_PREFIX = `values`; + return ; +} + +// Reset the variables and call ChartsList, when the protocol is artifacthub +export function CommunityChartList(metadataName: string, namespace: string, chartUrl: string) { + ResetGlobalVars(metadataName, namespace, chartUrl, COMMUNITY_REPO); + return ; +} + +// Fetch the list of services in the cluster +export function fetchCatalogs() { + // Use query parameter to fetch the services with label catalog.ocne.io/is-catalog + const queryParam: QueryParameters = { + labelSelector: LABEL_CATALOG + '=', + }; + return request(SERVICE_ENDPOINT, {}, true, true, queryParam).then(response => response); +} diff --git a/app-catalog/src/api/charts.tsx b/app-catalog/src/api/charts.tsx index 94f5cef2d..25ba41a19 100644 --- a/app-catalog/src/api/charts.tsx +++ b/app-catalog/src/api/charts.tsx @@ -1,4 +1,18 @@ -import { PAGE_OFFSET_COUNT_FOR_CHARTS } from '../components/charts/List'; +import { request } from '@kinvolk/headlamp-plugin/lib/ApiProxy'; +import { + COMMUNITY_REPO, + CUSTOM_CHART_VALUES_PREFIX, + PAGE_OFFSET_COUNT_FOR_CHARTS, + VANILLA_HELM_REPO, +} from '../components/charts/List'; +import { yamlToJSON } from '../helpers'; +import { isElectron } from '../index'; + +const SERVICE_PROXY = '/serviceproxy'; + +const getURLSearchParams = url => { + return new URLSearchParams({ request: url }).toString(); +}; export async function fetchChartsFromArtifact( search: string = '', @@ -7,6 +21,46 @@ export async function fetchChartsFromArtifact( page: number, limit: number = PAGE_OFFSET_COUNT_FOR_CHARTS ) { + if (!isElectron()) { + if (CHART_PROFILE === VANILLA_HELM_REPO) { + // When CHART_PROFILE is VANILLA_HELM_REPOSITORY, the code expects /charts/index.yaml + // to contain the metadata of the available charts + const url = + `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + + getURLSearchParams(`charts/index.yaml`); + + // Ensure that the UI renders index.yaml in yaml and json format. Please note that, helm repo index generates index.yaml + // in yaml as the default format, although latest versions support generating the file in json format. + // The API yamlToJSON works for the response in yaml as well as json format. + //const dataResponse = request(url, { isJSON: false }, true, true, {}) + // .then(response => response.text()) + // .then(yamlResponse => yamlToJSON(yamlResponse)); + const dataResponse = await request(url, { isJSON: false }, true, true, {}); + const total=0; + return { dataResponse, total }; + } else if (CHART_PROFILE === COMMUNITY_REPO) { + let requestParam = ''; + if (!category || category.value === 0) { + requestParam = `api/v1/packages/search?kind=0&ts_query_web=${search}&sort=relevance&facets=true&limit=${limit}&offset=${ + (page - 1) * limit + }`; + } else { + requestParam = `api/v1/packages/search?kind=0&ts_query_web=${search}&category=${ + category.value + }&sort=relevance&facets=true&limit=${limit}&offset=${(page - 1) * limit}`; + } + + const url = + `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + getURLSearchParams(requestParam); + const response = request(url, {}, true, true, {}).then(response => response); + const dataResponse = response + const total = response.headers.get('pagination-total-count'); + return { dataResponse, total }; + //return request(url, {}, true, true, {}).then(response => response); + } + } + + // App-catalog desktop version // note: we are currently defaulting to search by verified and official as default const url = new URL('https://artifacthub.io/api/v1/packages/search'); url.searchParams.set('offset', ((page - 1) * limit).toString()); @@ -30,6 +84,16 @@ export async function fetchChartsFromArtifact( } export function fetchChartDetailFromArtifact(chartName: string, repoName: string) { + // Use /serviceproxy to fetch the resource, by specifying the access token + if (!isElectron() && CHART_PROFILE === COMMUNITY_REPO) { + const url = + `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + + getURLSearchParams(`api/v1/packages/helm/${repoName}/${chartName}`); + return request(url, {}, true, true, {}).then(response => response); + } + + // Use /externalproxy for App-catalog desktop version + return fetch(`http://localhost:4466/externalproxy`, { headers: { 'Forward-To': `https://artifacthub.io/api/v1/packages/helm/${repoName}/${chartName}`, @@ -38,9 +102,39 @@ export function fetchChartDetailFromArtifact(chartName: string, repoName: string } export function fetchChartValues(packageID: string, packageVersion: string) { + if (!isElectron()) { + let requestParam = ''; + if (CHART_PROFILE === VANILLA_HELM_REPO) { + // When the token CUSTOM_CHART_VALUES_PREFIX is replaced during the deployment, expect the values.yaml for the specified + // package and version accessible on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml + if (CUSTOM_CHART_VALUES_PREFIX !== 'CUSTOM_CHART_VALUES_PREFIX') { + globalThis.CHART_VALUES_PREFIX = `${CUSTOM_CHART_VALUES_PREFIX}`; + } + // The code expects /${packageID}/${packageVersion}/values.yaml to return values.yaml for the component + // denoted by packageID and a given packageVersion. Please note that, chart.name is used for packageID in this case. + requestParam = `${CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml`; + } else if (CHART_PROFILE === COMMUNITY_REPO) { + requestParam = `api/v1/packages/${packageID}/${packageVersion}/values`; + } + const url = + `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + getURLSearchParams(requestParam); + + // Use /serviceproxy to fetch the resource, by specifying the access token + return request(url, { isJSON: false }, true, true, {}).then(response => response.text()); + } + + // Use /externalproxy for App-catalog desktop version return fetch(`http://localhost:4466/externalproxy`, { headers: { 'Forward-To': `https://artifacthub.io/api/v1/packages/${packageID}/${packageVersion}/values`, }, }).then(response => response.text()); } + +export async function fetchChartIcon(iconName: string) { + const url = + `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + + getURLSearchParams(`${iconName}`); + return request(url, {isJSON: false}, true, true, {}).then(response => response); +} + diff --git a/app-catalog/src/components/charts/Details.tsx b/app-catalog/src/components/charts/Details.tsx index 300aba11e..5e6217aeb 100644 --- a/app-catalog/src/components/charts/Details.tsx +++ b/app-catalog/src/components/charts/Details.tsx @@ -13,6 +13,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import remarkGfm from 'remark-gfm'; import { fetchChartDetailFromArtifact } from '../../api/charts'; import { EditorDialog } from './EditorDialog'; +import { VANILLA_HELM_REPO } from './List'; const { createRouteURL } = Router; export default function ChartDetails() { @@ -31,6 +32,12 @@ export default function ChartDetails() { const [openEditor, setOpenEditor] = useState(false); useEffect(() => { + // Note: This path is not enabled for vanilla helm repo. Please check the following comment in charts/List.tsx + // TODO: The app-catalog using artifacthub.io loads the details about the chart with an option to install the chart + // + // An API to get details about a particular chart is required to achieve this. For example, take a look at the response + // from https://artifacthub.io/api/v1/packages/helm/grafana/grafana + // Easiest thing is to fetch index.yaml, get the details for chartName and fill the details fetchChartDetailFromArtifact(chartName, repoName).then(response => { setChart(response); }); @@ -81,12 +88,16 @@ export default function ChartDetails() { {chart.logo_image_id && ( - {chart.name} + {CHART_PROFILE === VANILLA_HELM_REPO ? ( + {chart.name} + ) : ( + {chart.name} + )} )} {chart.name} diff --git a/app-catalog/src/components/charts/EditorDialog.tsx b/app-catalog/src/components/charts/EditorDialog.tsx index 2c5db6aa1..39a93ec45 100644 --- a/app-catalog/src/components/charts/EditorDialog.tsx +++ b/app-catalog/src/components/charts/EditorDialog.tsx @@ -10,6 +10,8 @@ import { fetchChartDetailFromArtifact, fetchChartValues } from '../../api/charts import { createRelease, getActionStatus } from '../../api/releases'; import { addRepository } from '../../api/repository'; import { jsonToYAML, yamlToJSON } from '../../helpers'; +import { APP_CATALOG_HELM_REPOSITORY,VANILLA_HELM_REPO } from './List'; +//import * as global from "global"; type FieldType = { value: string; @@ -53,8 +55,22 @@ export function EditorDialog(props: { } }, [selectedNamespace, namespaceNames]); + // Fetch chart values for a given package and version + function refreshChartValue(packageID: string, packageVersion: string) { + fetchChartValues(packageID, packageVersion) + .then((response: any) => { + setChartValues(response); + setDefaultChartValues(yamlToJSON(response)); + }) + .catch(error => { + enqueueSnackbar(`Error fetching chart values ${error}`, { + variant: 'error', + }); + }); + } + function handleChartValueFetch(chart: any) { - const packageID = chart.package_id; + const packageID = globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? chart.name : chart.package_id; const packageVersion = selectedVersion?.value ?? chart.version; setChartValuesLoading(true); fetchChartValues(packageID, packageVersion) @@ -73,17 +89,22 @@ export function EditorDialog(props: { } useEffect(() => { - setChartInstallDescription(`${chart.name} deployment`); - fetchChartDetailFromArtifact(chart.name, chart.repository.name).then(response => { - if (response.available_versions) { - const availableVersions = response.available_versions.map(({ version }) => ({ - title: version, - value: version, - })); - setVersions(availableVersions); - setSelectedVersion(availableVersions[0]); - } - }); + if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO) { + const versionsArray = AVAILABLE_VERSIONS.get(chart.name); + const availableVersions = versionsArray.map(({ version }) => ({ title: version, value: version })); + // @ts-ignore + setVersions(availableVersions); + setChartInstallDescription(`${chart.name} deployment`); + setSelectedVersion(availableVersions[0]); + } else { + fetchChartDetailFromArtifact(chart.name, chart.repository.name).then(response => { + if (response.available_versions) { + const availableVersions = response.available_versions.map(({ version }) => ({ title: version, value: version })); + setVersions(availableVersions); + setSelectedVersion(availableVersions[0]); + } + }); + } handleChartValueFetch(chart); }, [chart]); @@ -144,14 +165,20 @@ export function EditorDialog(props: { }); return; } - const repoName = chart.repository.name; - const repoURL = chart.repository.url; + const jsonChartValues = yamlToJSON(chartValues); const chartValuesDIFF = _.omitBy(jsonChartValues, (value, key) => _.isEqual(defaultChartValues[key], value) ); setInstallLoading(true); + // In case of profile: VANILLA_HELM_REPOSITORY, set the URL to access the index.yaml of the chart, as the repoURL. + // During the installation of an application, this URL will be added as a chart repository, and the list of available versions + // will be loaded during the upgrade from this repository. + const repoURL = + globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? `${CHART_URL_PREFIX}/charts/` : chart.repository.url; + const repoName = + globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? APP_CATALOG_HELM_REPOSITORY : chart.repository.name; addRepository(repoName, repoURL) .then(() => { createRelease( @@ -264,6 +291,11 @@ export function EditorDialog(props: { value={selectedVersion ?? versions[0]} // @ts-ignore onChange={(event, newValue: FieldType) => { + if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO && chart.version !== newValue.value) { + console.log('Time to change'); + // Refresh values.yaml for a chart when the current version and new version differ + refreshChartValue(chart.name, newValue.value); + } setSelectedVersion(newValue); }} renderInput={params => ( diff --git a/app-catalog/src/components/charts/List.tsx b/app-catalog/src/components/charts/List.tsx index 653752055..87e22e020 100644 --- a/app-catalog/src/components/charts/List.tsx +++ b/app-catalog/src/components/charts/List.tsx @@ -20,11 +20,13 @@ import { } from '@mui/material'; import { Autocomplete, Pagination } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; -//import { jsonToYAML, yamlToJSON } from '../../helpers'; -import { fetchChartsFromArtifact } from '../../api/charts'; +import { yamlToJSON } from '../../helpers'; +import { fetchChartIcon, fetchChartsFromArtifact } from '../../api/charts'; +import { AvailableComponentVersions } from '../../helpers/catalog'; //import { createRelease } from '../../api/releases'; import { EditorDialog } from './EditorDialog'; import { SettingsLink } from './SettingsLink'; +//import * as global from "global"; interface AppCatalogConfig { /** @@ -38,6 +40,25 @@ const useStoreConfig = store.useConfig(); export const PAGE_OFFSET_COUNT_FOR_CHARTS = 9; +export const VANILLA_HELM_REPO = 'VANILLA_HELM_REPOSITORY'; + +export const COMMUNITY_REPO = 'COMMUNITY_REPOSITORY'; + +// Replace the token with the URL prefix to values.yaml for a component on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml +// This is used only for the catalog provided by a vanilla Helm repository. +// For the default behavior when this token is not replaced during deployment, please take a look at the global variable CHART_VALUES_PREFIX and its +// usage in src/api/catalogs.tsx +export const CUSTOM_CHART_VALUES_PREFIX = 'CUSTOM_CHART_VALUES_PREFIX'; + +// The name of the helm repository added before installing an application, while using vanilla helm repository +export const APP_CATALOG_HELM_REPOSITORY = 'app-catalog'; + +// Define a global variable to hold the available versions of the components in the catalog +declare global { + var AVAILABLE_VERSIONS: Map; +} + + interface SearchProps { search: string; setSearch: React.Dispatch>; @@ -147,6 +168,7 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { const [chartCategory, setChartCategory] = useState(helmChartCategoryList[0]); const [search, setSearch] = useState(''); const [selectedChartForInstall, setSelectedChartForInstall] = useState(null); + const [iconUrls, setIconUrls] = useState<{ [url: string]: string }>({}); // New state for multiple icon URLs // note: since we default to true for showOnlyVerified and the settings page is not accessible from anywhere else but the list comp // we must have the default value here and have it imported for use in the settings tab @@ -165,9 +187,18 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { async function fetchData() { try { - const response: any = await fetchCharts(search, showOnlyVerified, chartCategory, page); - setCharts(response.dataResponse.packages); - setChartsTotalCount(parseInt(response.total)); + const response = await fetchCharts(search, showOnlyVerified, chartCategory, page); + const data = await response.dataResponse.text(); + const d=yamlToJSON(data); + if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO) { + setCharts(d.entries); + setChartsTotalCount(parseInt(response.total)); + // Capture available versions from the response and set AVAILABLE_VERSIONS + globalThis.AVAILABLE_VERSIONS = AvailableComponentVersions(d.entries); + } else { + setCharts(d.packages); + setChartsTotalCount(parseInt(response.total)); + } } catch (err) { console.error('Error fetching charts', err); setCharts([]); @@ -179,6 +210,69 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { [page, chartCategory, search, showOnlyVerified] ); + useEffect(() => { + if (charts && Object.keys(charts).length > 0) { + const fetchIcons = async () => { + try { + const iconUrls = {}; + const iconPromises = Object.values(charts).flatMap(chartArray => + chartArray.map(async chart => { + const iconURL = chart.icon ?? ''; + if (iconURL === '') { + return; + } + const isURL = (urlString) => { + try { + new URL(urlString); + return true; + } catch (e) { + return false; + } + }; + if (isURL(iconURL)) { + // may be an external icon URL, so, just use as is + iconUrls[iconURL] = iconURL + } else { + const p = await fetchChartIcon(iconURL) + .then((response: any) => { + const contentType = response.headers.get('Content-Type'); + if (contentType.includes('image/svg+xml') || contentType.includes('text/xml') || contentType.includes('text/plain')) { + response.text() + .then((txt) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => reader.result + reader.onerror = reject; + iconUrls[iconURL] = `data:image/svg+xml;utf8,${encodeURIComponent(txt)}`; + }) + ); + } else if (contentType.includes('image/')) { + response.blob() + .then((blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => reader.result + reader.onerror = reject; + reader.readAsDataURL(blob); + iconUrls[iconURL] = URL.createObjectURL(blob); + }) + ); + } + }) + .catch(error => console.error("failed to fetch icon:", error)) + } + }) + ); + await Promise.all(iconPromises); + setIconUrls(iconUrls); + } catch (error) { + console.error("Error fetching icons:", error); + } + }; + fetchIcons(); + } + }, [charts]); + return ( <> - {charts.map(chart => { - return ( - - - {chart.logo_image_id && ( - - )} - - {(chart.cncf || chart.repository.cncf) && ( - - - - )} - {(chart.official || chart.repository.official) && ( - - - - )} - {chart.repository.verified_publisher && ( - - - - )} - - - - key.match(search)) + .reduce((obj, key) => { + return Object.assign(obj, { + [key]: charts[key], + }); + }, {}) + ).map(chartName => { + // When a chart contains multiple versions, only display the first version + return charts[chartName].slice(0, 1).map(chart => { + return ( + - - - - {chart.name} - - - - - - v{chart.version} - - {chart?.repository?.name || ''} - + {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? ( + iconUrls[chart.icon] && ( + + ) + ) : ( + chart.logo_image_id && ( + + ) + )} + + {(chart?.cncf || chart?.repository?.cncf) && ( + + + + )} + {(chart?.official || chart?.repository?.official) && ( + + + + )} + {chart?.repository?.verified_publisher && ( + + + + )} + - - - - - {chart?.description?.slice(0, 100)} - {chart?.description?.length > 100 && ( - - + + + + + {/* TODO: The app-catalog using artifacthub.io loads the details about the chart with an option to install the chart + Fix this for vanilla helm repo */} + {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? chart.name : + ( + + {chart.name} + + )} + + - )} - - - - - - - Learn More - - - - ); + + + {/* If the chart.version contains v prefix, remove it */} + {chart.version.startsWith('v') ? ( + {chart.version} + ) : ( + v{chart.version} + )} + + + {chart?.repository?.name || ''} + + + + + + + {chart?.description?.slice(0, 100)} + {chart?.description?.length > 100 && ( + + + + )} + + + + + + {/* + Provide Learn More link only when the chart has source + When there are multiple sources for a chart, use the first source for the link, rather than using comma separated values + */} + {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? ( + !chart?.sources ? ( + '' + ) : chart?.sources?.length === 1 ? ( + + Learn More + + ) : ( + + Learn More + + ) + ):( + + Learn More + + )} + + + ); + }); })} )} @@ -398,11 +551,13 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { /> )} - - - Powered by ArtifactHub - - + {globalThis.CHART_PROFILE !== VANILLA_HELM_REPO && ( + + + Powered by ArtifactHub + + + )} ); } diff --git a/app-catalog/src/components/releases/EditorDialog.tsx b/app-catalog/src/components/releases/EditorDialog.tsx index 5b6242756..0f538ed8a 100644 --- a/app-catalog/src/components/releases/EditorDialog.tsx +++ b/app-catalog/src/components/releases/EditorDialog.tsx @@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from 'react'; import semver from 'semver'; import { fetchChart, getActionStatus, upgradeRelease } from '../../api/releases'; import { jsonToYAML, yamlToJSON } from '../../helpers'; +import {APP_CATALOG_HELM_REPOSITORY} from "../charts/List"; export function EditorDialog(props: { openEditor: boolean; @@ -64,7 +65,8 @@ export function EditorDialog(props: { let response; let error: Error | null = null; try { - response = await fetchChart(release.chart.metadata.name); + const metadataName = release.chart.metadata.name === APP_CATALOG_HELM_REPOSITORY ? '/' + release.chart.metadata.name : release.chart.metadata.name; + response = await fetchChart(metadataName); } catch (err) { error = err; } @@ -74,11 +76,10 @@ export function EditorDialog(props: { } if (!!error) { - enqueueSnackbar(`Error fetching chart versions: ${error}`, { + enqueueSnackbar(`Error fetching chart versions: ${error.message}`, { variant: 'error', autoHideDuration: 5000, }); - return; } setIsLoading(false); diff --git a/app-catalog/src/helpers/catalog.ts b/app-catalog/src/helpers/catalog.ts new file mode 100644 index 000000000..f6bd00df9 --- /dev/null +++ b/app-catalog/src/helpers/catalog.ts @@ -0,0 +1,89 @@ +import { fetchCatalogs } from '../api/catalogs'; + +const ANNOTATION_URI = 'catalog.ocne.io/uri'; +const ANNOTATION_NAME = 'catalog.ocne.io/name'; +const ANNOTATION_PROTOCOL = 'catalog.ocne.io/protocol'; +const ANNOTATION_DISPLAY_NAME = 'catalog.ocne.io/displayName'; + +const DEFAULT_CATALOG_NAME = 'app-catalog'; +const DEFAULT_CATALOG_NAMESPACE = 'ocne-system'; + +// Catalog interface, containing information relevant to register a catalog in the sidebar +interface Catalog { + name: string; + displayName: string; + metadataName: string; + namespace: string; + protocol: string; + uri: string; +} + +// An interface to define component versions +interface ComponentVersions { + version: string; +} + +// Fetch the list of catalogs installed +export function CatalogLists() { + return fetchCatalogs().then(function (response) { + const catalogList: Array = new Array(); + for (let i = 0; i < response.items.length; i++) { + let serviceUri = ''; + const metadata = response.items[i].metadata; + if (ANNOTATION_URI in metadata.annotations) { + serviceUri = metadata.annotations[ANNOTATION_URI]; + } + + // Using the first port + if (serviceUri === '') { + const port = response.items[i].spec.ports[0]; + serviceUri = port.name + '://' + metadata.name + '.' + metadata.namespace + ':' + port.port; + } + + let catalogDisplayName = ''; + if (ANNOTATION_DISPLAY_NAME in metadata.annotations && metadata.annotations[ANNOTATION_DISPLAY_NAME] != '') { + catalogDisplayName = metadata.annotations[ANNOTATION_DISPLAY_NAME] + } else { + catalogDisplayName = metadata.annotations[ANNOTATION_NAME] + } + + const catalog: Catalog = { + name: metadata.name + '-' + metadata.namespace, + // If there are 2 catalogs deployed with same name, the sidebar will be same. If we use the namespace, + // the sidebar will be too long + displayName: catalogDisplayName, + metadataName: metadata.name, + namespace: metadata.namespace, + protocol: metadata.annotations[ANNOTATION_PROTOCOL], + uri: serviceUri, + }; + + // Insert the default catalog as the first element of the array + if ( + metadata.name === DEFAULT_CATALOG_NAME && + metadata.namespace === DEFAULT_CATALOG_NAMESPACE + ) { + catalogList.unshift(catalog); + } else { + catalogList.push(catalog); + } + } + return catalogList; + }); +} + +// Return a map with component as the key and an array of versions as the value +export function AvailableComponentVersions(chartEntries: any[]) { + const compVersions = new Map(); + for (const [key, value] of Object.entries(chartEntries)) { + const versions: Array = new Array(); + for (let i = 0; i < value.length; i++) { + const v: ComponentVersions = { + version: value[i].version, + }; + versions.push(v); + } + compVersions.set(key, versions); + } + return compVersions; +} diff --git a/app-catalog/src/index.tsx b/app-catalog/src/index.tsx index dc019e238..1a79c8be6 100644 --- a/app-catalog/src/index.tsx +++ b/app-catalog/src/index.tsx @@ -3,11 +3,18 @@ import { registerRoute, registerSidebarEntry, } from '@kinvolk/headlamp-plugin/lib'; +import { + ARTIFACTHUB_PROTOCOL, + CommunityChartList, + HELM_PROTOCOL, + HelmChartList, +} from './api/catalogs'; import { AppCatalogSettings } from '../src/components/settings/AppCatalogSettings'; import ChartDetails from './components/charts/Details'; import { ChartsList } from './components/charts/List'; import ReleaseDetail from './components/releases/Detail'; import ReleaseList from './components/releases/List'; +import { CatalogLists } from './helpers/catalog'; export function isElectron(): boolean { // Renderer process @@ -64,23 +71,80 @@ if (isElectron()) { parent: 'Helm', label: 'Installed', }); +} else { + // Iterate the list of c, to register the sidebar and the respective routes + CatalogLists().then(chart => { + for (let i = 0; i < chart.length; i++) { + // Register the sidebar for Apps, with the URL pointing to the first chart returned + if (i === 0) { + registerSidebarEntry({ + name: 'Helm', + url: '/apps/' + chart[i].name, + icon: 'mdi:apps-box', + parent: '', + label: 'Apps', + }); + } - registerRoute({ - path: '/apps/installed', - sidebar: 'Releases', - name: 'Releases', - exact: true, - component: () => , - }); + // Register the sidebars for the catalogs, as returned by the API + registerSidebarEntry({ + name: 'Charts ' + chart[i].name, + url: '/apps/' + chart[i].name, + icon: '', + parent: 'Helm', + label: chart[i].displayName, + }); - registerRoute({ - path: '/helm/:namespace/releases/:releaseName', - sidebar: 'Releases', - name: 'Release Detail', - exact: true, - component: () => , + // Register the sidebar with label - Installed, as the last entry under Apps + if (i === chart.length - 1) { + registerSidebarEntry({ + name: 'Releases', + url: '/apps/installed', + icon: '', + parent: 'Helm', + label: 'Installed', + }); + } + + if (chart[i].protocol === HELM_PROTOCOL) { + registerRoute({ + path: '/apps/' + chart[i].name, + sidebar: 'Charts ' + chart[i].name, + name: 'Charts ' + chart[i].name, + exact: true, + component: () => HelmChartList(chart[i].metadataName, chart[i].namespace, chart[i].uri), + }); + } else if (chart[i].protocol === ARTIFACTHUB_PROTOCOL) { + registerRoute({ + path: '/apps/' + chart[i].name, + sidebar: 'Charts ' + chart[i].name, + name: 'Charts ' + chart[i].name, + exact: true, + component: () => + CommunityChartList(chart[i].metadataName, chart[i].namespace, chart[i].uri), + }); + } + } }); +} +registerRoute({ + path: '/apps/installed', + sidebar: 'Releases', + name: 'Releases', + exact: true, + component: () => , +}); + +registerRoute({ + path: '/helm/:namespace/releases/:releaseName', + sidebar: 'Releases', + name: 'Release Detail', + exact: true, + component: () => , +}); + +if (isElectron()) { registerRoute({ path: '/apps/catalog', sidebar: 'Charts', @@ -88,22 +152,22 @@ if (isElectron()) { exact: true, component: () => , }); +} - registerRoute({ - path: '/helm/:repoName/charts/:chartName', - sidebar: 'Charts', - name: 'Charts', - exact: true, - component: () => , - }); +registerRoute({ + path: '/helm/:repoName/charts/:chartName', + sidebar: 'Charts', + name: 'Charts', + exact: true, + component: () => , +}); - registerRoute({ - path: '/settings/plugins/app-catalog', - sidebar: 'Charts', - name: 'App Catalog', - exact: true, - component: () => , - }); -} +registerRoute({ + path: '/settings/plugins/app-catalog', + sidebar: 'Charts', + name: 'App Catalog', + exact: true, + component: () => , +}); registerPluginSettings('app-catalog', AppCatalogSettings, false); From 31ea85fb79adc242068c10e59157fed966be14b1 Mon Sep 17 00:00:00 2001 From: Murali Annamneni Date: Wed, 20 Aug 2025 19:12:54 +0000 Subject: [PATCH 2/3] app-catalog: Convert global constants with shared contants and address review comments Co-authored-by: Vrushali Shah Signed-off-by: Murali Annamneni --- app-catalog/src/api/catalogConfig.tsx | 51 ++ app-catalog/src/api/catalogs.tsx | 77 ++- app-catalog/src/api/charts.tsx | 106 ++-- app-catalog/src/components/charts/Details.tsx | 46 +- .../src/components/charts/EditorDialog.tsx | 51 +- app-catalog/src/components/charts/List.tsx | 496 +++++++++--------- .../src/components/releases/EditorDialog.tsx | 24 +- app-catalog/src/components/releases/List.tsx | 7 + app-catalog/src/constants/catalog.ts | 16 + app-catalog/src/helpers/catalog.ts | 37 +- app-catalog/src/helpers/index.tsx | 4 +- app-catalog/src/index.tsx | 12 +- app-catalog/tsconfig.json | 2 +- 13 files changed, 561 insertions(+), 368 deletions(-) create mode 100644 app-catalog/src/api/catalogConfig.tsx create mode 100644 app-catalog/src/constants/catalog.ts diff --git a/app-catalog/src/api/catalogConfig.tsx b/app-catalog/src/api/catalogConfig.tsx new file mode 100644 index 000000000..b27961e79 --- /dev/null +++ b/app-catalog/src/api/catalogConfig.tsx @@ -0,0 +1,51 @@ +/** + * Configuration object for chart settings. + * + * @property chartURLPrefix - The prefix for chart URLs. + * @property chartProfile - The profile for charts. + * @property chartValuesPrefix - The prefix for chart values. + * @property catalogNamespace - The namespace for the catalog. + * @property catalogName - The name of the catalog. + */ +export type ChartConfig = { + chartURLPrefix: string; + chartProfile: string; + chartValuesPrefix: string; + catalogNamespace: string; + catalogName: string; +}; + +const catalogConfig: ChartConfig = { + chartURLPrefix: '', + chartProfile: '', + chartValuesPrefix: '', + catalogNamespace: '', + catalogName: '', +}; + +/** + * Sets the prefix for chart values in the catalog configuration. + * @param valuesPrefix - The prefix to be used for chart values. + * @param valuesPrefix - The new prefix for chart values. + */ +export function setChartValuesPrefix(valuesPrefix: string) { + catalogConfig.chartValuesPrefix = valuesPrefix; +} + +/** + * Updates the catalog configuration with the provided partial configuration. + * + * @param update - A partial ChartConfig object containing the properties to be updated. + */ +export function setCatalogConfig(update: Partial) { + Object.assign(catalogConfig, update); +} + +/** + * Retrieves the current catalog configuration. + * + * @returns {Readonly} The current catalog configuration. + */ +export function getCatalogConfig(): Readonly { + return catalogConfig; +} diff --git a/app-catalog/src/api/catalogs.tsx b/app-catalog/src/api/catalogs.tsx index 77dc42322..ec474da5a 100644 --- a/app-catalog/src/api/catalogs.tsx +++ b/app-catalog/src/api/catalogs.tsx @@ -1,51 +1,72 @@ import { QueryParameters, request } from '@kinvolk/headlamp-plugin/lib/ApiProxy'; -import { ChartsList, COMMUNITY_REPO, VANILLA_HELM_REPO } from '../components/charts/List'; +import { ChartsList } from '../components/charts/List'; +import { COMMUNITY_REPO, VANILLA_HELM_REPO } from '../constants/catalog'; +import { setCatalogConfig } from './catalogConfig'; const SERVICE_ENDPOINT = '/api/v1/services'; -const LABEL_CATALOG = 'catalog.ocne.io/is-catalog'; +const LABEL_CATALOG = 'catalog.headlamp.dev/is-catalog'; -declare global { - var CHART_URL_PREFIX: string; - var CHART_PROFILE: string; - var CHART_VALUES_PREFIX: string; - var CATALOG_NAMESPACE: string; - var CATALOG_NAME: string; -} - -// Reset the CHART_URL_PREFIX and CHART_PROFILE, to switch between different catalogs +/** + * Resets the global variables used for chart configuration. + * Reset the prefix and profile, to switch between different catalogs + * @param metadataName - The metadata name of the catalog. + * @param namespace - The namespace of the catalog. + * @param prefix - The prefix for chart URLs. + * @param profile - The profile for charts. + * @param valuesPrefix - The prefix for chart values. + */ export function ResetGlobalVars( metadataName: string, namespace: string, prefix: string, - profile: string + profile: string, + valuesPrefix: string ) { - globalThis.CATALOG_NAME = metadataName; - globalThis.CATALOG_NAMESPACE = namespace; - globalThis.CHART_URL_PREFIX = prefix; - globalThis.CHART_PROFILE = profile; + setCatalogConfig({ + chartURLPrefix: prefix, + chartProfile: profile, + chartValuesPrefix: valuesPrefix, + catalogNamespace: namespace, + catalogName: metadataName, + }); } -// Constants for the supported protocols -export const HELM_PROTOCOL = 'helm'; -export const ARTIFACTHUB_PROTOCOL = 'artifacthub'; - -// Reset the variables and call ChartsList, when the protocol is helm +/** + * Resets the global variables and renders the ChartsList component for a Helm chart. + * + * @param metadataName - The metadata name of the catalog. + * @param namespace - The namespace of the catalog. + * @param chartUrl - The URL of the Helm chart repository. + * + * @returns The ChartsList component. + */ export function HelmChartList(metadataName: string, namespace: string, chartUrl: string) { - ResetGlobalVars(metadataName, namespace, chartUrl, VANILLA_HELM_REPO); - // Let the default value for CHART_VALUES_PREFIX be "values" - globalThis.CHART_VALUES_PREFIX = `values`; + ResetGlobalVars(metadataName, namespace, chartUrl, VANILLA_HELM_REPO, `values`); return ; } -// Reset the variables and call ChartsList, when the protocol is artifacthub +/** + * Resets the global variables and renders the ChartsList component for a community chart(artifacthub). + * + * @param metadataName - The metadata name of the catalog. + * @param namespace - The namespace of the catalog. + * @param chartUrl - The URL of the community chart repository. + * + * @returns The ChartsList component for the community chart. + */ export function CommunityChartList(metadataName: string, namespace: string, chartUrl: string) { - ResetGlobalVars(metadataName, namespace, chartUrl, COMMUNITY_REPO); + ResetGlobalVars(metadataName, namespace, chartUrl, COMMUNITY_REPO, ''); return ; } -// Fetch the list of services in the cluster +/** + * Fetches the list of catalogs in the cluster. + * It uses a query parameter to fetch services with the given label. + * + * @returns A promise resolving to the response from the request. + */ export function fetchCatalogs() { - // Use query parameter to fetch the services with label catalog.ocne.io/is-catalog + // Use query parameter to fetch the services with label catalog.headlamp.dev/is-catalog const queryParam: QueryParameters = { labelSelector: LABEL_CATALOG + '=', }; diff --git a/app-catalog/src/api/charts.tsx b/app-catalog/src/api/charts.tsx index 25ba41a19..5a74be865 100644 --- a/app-catalog/src/api/charts.tsx +++ b/app-catalog/src/api/charts.tsx @@ -4,16 +4,35 @@ import { CUSTOM_CHART_VALUES_PREFIX, PAGE_OFFSET_COUNT_FOR_CHARTS, VANILLA_HELM_REPO, -} from '../components/charts/List'; +} from '../constants/catalog'; import { yamlToJSON } from '../helpers'; import { isElectron } from '../index'; +import { getCatalogConfig, setChartValuesPrefix } from './catalogConfig'; +// Headlamp plugin's backend service proxy endpoint. +// It was implemented by Headlamp's backed to proxies in-cluster requests to handle authentication const SERVICE_PROXY = '/serviceproxy'; +/** + * Encodes a URL as a query parameter for another URL. + * + * @param url - The URL to be encoded as a query parameter. + * @returns The encoded URL as a query parameter. + */ const getURLSearchParams = url => { return new URLSearchParams({ request: url }).toString(); }; +/** + * Fetches charts from the Artifact repository based on the provided search criteria. + * + * @param search - The search query to filter charts. + * @param verified - Whether to fetch charts from verified publishers. + * @param category - The category to filter charts by. + * @param page - The page number to fetch. + * @param [limit=PAGE_OFFSET_COUNT_FOR_CHARTS] - The number of charts to fetch per page. + * @returns An object containing the fetched charts and the total count. + */ export async function fetchChartsFromArtifact( search: string = '', verified: boolean, @@ -22,23 +41,23 @@ export async function fetchChartsFromArtifact( limit: number = PAGE_OFFSET_COUNT_FOR_CHARTS ) { if (!isElectron()) { - if (CHART_PROFILE === VANILLA_HELM_REPO) { - // When CHART_PROFILE is VANILLA_HELM_REPOSITORY, the code expects /charts/index.yaml + const chartCfg = getCatalogConfig(); + if (chartCfg.chartProfile === VANILLA_HELM_REPO) { + // When chartProfile is VANILLA_HELM_REPOSITORY, the code expects /charts/index.yaml // to contain the metadata of the available charts const url = - `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + + `${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` + getURLSearchParams(`charts/index.yaml`); // Ensure that the UI renders index.yaml in yaml and json format. Please note that, helm repo index generates index.yaml // in yaml as the default format, although latest versions support generating the file in json format. // The API yamlToJSON works for the response in yaml as well as json format. - //const dataResponse = request(url, { isJSON: false }, true, true, {}) - // .then(response => response.text()) - // .then(yamlResponse => yamlToJSON(yamlResponse)); const dataResponse = await request(url, { isJSON: false }, true, true, {}); - const total=0; - return { dataResponse, total }; - } else if (CHART_PROFILE === COMMUNITY_REPO) { + const yamlResponse = (await dataResponse?.text()) ?? ''; + const jsonResponse = yamlToJSON(yamlResponse) as Record; + const total = Object.keys(jsonResponse.entries ?? {}).length; + return { data: jsonResponse, total }; + } else if (chartCfg.chartProfile === COMMUNITY_REPO) { let requestParam = ''; if (!category || category.value === 0) { requestParam = `api/v1/packages/search?kind=0&ts_query_web=${search}&sort=relevance&facets=true&limit=${limit}&offset=${ @@ -51,12 +70,12 @@ export async function fetchChartsFromArtifact( } const url = - `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + getURLSearchParams(requestParam); - const response = request(url, {}, true, true, {}).then(response => response); - const dataResponse = response - const total = response.headers.get('pagination-total-count'); - return { dataResponse, total }; - //return request(url, {}, true, true, {}).then(response => response); + `${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` + + getURLSearchParams(requestParam); + const dataResponse = await request(url, {}, true, true, {}).then(response => response); + const jsonResponse = await dataResponse.json(); + const total = dataResponse.headers?.get('pagination-total-count') ?? 0; + return { data: jsonResponse, total }; } } @@ -76,18 +95,24 @@ export async function fetchChartsFromArtifact( url.searchParams.set('verified_publisher', verified.toString()); const response = await fetch(url.toString()); - const total = response.headers.get('pagination-total-count'); - - const dataResponse = await response.json(); - - return { dataResponse, total }; + const total = response.headers?.get('pagination-total-count') ?? 0; + const jsonResponse = await response.json(); + return { data: jsonResponse, total }; } +/** + * Fetches the details of a chart from the Artifact repository. + * + * @param chartName - The name of the chart to fetch details for. + * @param repoName - The name of the repository where the chart is located. + * @returns A promise that resolves to the chart details. + */ export function fetchChartDetailFromArtifact(chartName: string, repoName: string) { + const chartCfg = getCatalogConfig(); // Use /serviceproxy to fetch the resource, by specifying the access token - if (!isElectron() && CHART_PROFILE === COMMUNITY_REPO) { + if (!isElectron() && chartCfg.chartProfile === COMMUNITY_REPO) { const url = - `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + + `${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` + getURLSearchParams(`api/v1/packages/helm/${repoName}/${chartName}`); return request(url, {}, true, true, {}).then(response => response); } @@ -101,23 +126,32 @@ export function fetchChartDetailFromArtifact(chartName: string, repoName: string }).then(response => response.json()); } +/** + * Fetches the chart values for a specific package and version. + * + * @param packageID - The ID of the package to fetch chart values for. + * @param packageVersion - The version of the package to fetch chart values for. + * @returns A promise that resolves to the chart values as a string. + */ export function fetchChartValues(packageID: string, packageVersion: string) { + const chartCfg = getCatalogConfig(); if (!isElectron()) { let requestParam = ''; - if (CHART_PROFILE === VANILLA_HELM_REPO) { + if (chartCfg.chartProfile === VANILLA_HELM_REPO) { // When the token CUSTOM_CHART_VALUES_PREFIX is replaced during the deployment, expect the values.yaml for the specified // package and version accessible on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml if (CUSTOM_CHART_VALUES_PREFIX !== 'CUSTOM_CHART_VALUES_PREFIX') { - globalThis.CHART_VALUES_PREFIX = `${CUSTOM_CHART_VALUES_PREFIX}`; + setChartValuesPrefix(`${CUSTOM_CHART_VALUES_PREFIX}`); } // The code expects /${packageID}/${packageVersion}/values.yaml to return values.yaml for the component // denoted by packageID and a given packageVersion. Please note that, chart.name is used for packageID in this case. - requestParam = `${CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml`; - } else if (CHART_PROFILE === COMMUNITY_REPO) { + requestParam = `${chartCfg.chartValuesPrefix}/${packageID}/${packageVersion}/values.yaml`; + } else if (chartCfg.chartProfile === COMMUNITY_REPO) { requestParam = `api/v1/packages/${packageID}/${packageVersion}/values`; } const url = - `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + getURLSearchParams(requestParam); + `${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` + + getURLSearchParams(requestParam); // Use /serviceproxy to fetch the resource, by specifying the access token return request(url, { isJSON: false }, true, true, {}).then(response => response.text()); @@ -131,10 +165,16 @@ export function fetchChartValues(packageID: string, packageVersion: string) { }).then(response => response.text()); } -export async function fetchChartIcon(iconName: string) { +/** + * Fetches the chart icon from the Artifact repository based on the provided icon name. + * + * @param iconName - The name of the icon to fetch. + * @returns A promise that resolves to the chart icon response. + */ +export async function fetchChartIcon(iconName: string) { + const chartCfg = getCatalogConfig(); const url = - `${SERVICE_PROXY}/${CATALOG_NAMESPACE}/${CATALOG_NAME}?` + - getURLSearchParams(`${iconName}`); - return request(url, {isJSON: false}, true, true, {}).then(response => response); + `${SERVICE_PROXY}/${chartCfg.catalogNamespace}/${chartCfg.catalogName}?` + + getURLSearchParams(`${iconName}`); + return request(url, { isJSON: false }, true, true, {}).then(response => response); } - diff --git a/app-catalog/src/components/charts/Details.tsx b/app-catalog/src/components/charts/Details.tsx index 5e6217aeb..d65fab868 100644 --- a/app-catalog/src/components/charts/Details.tsx +++ b/app-catalog/src/components/charts/Details.tsx @@ -11,25 +11,39 @@ import ReactMarkdown from 'react-markdown'; import { useParams } from 'react-router'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import remarkGfm from 'remark-gfm'; +import { getCatalogConfig } from '../../api/catalogConfig'; import { fetchChartDetailFromArtifact } from '../../api/charts'; import { EditorDialog } from './EditorDialog'; -import { VANILLA_HELM_REPO } from './List'; const { createRouteURL } = Router; -export default function ChartDetails() { +type ChartDetailsProps = { + vanillaHelmRepo: string; +}; + +/** + * Displays the details of a chart, including its name, description, maintainers, and readme. + * The component fetches the chart details from the Artifact repository based on the provided chart name and repository name. + * It also provides an option to install the chart. + * + * @param props.vanillaHelmRepo - The vanilla Helm repository. + * @returns The chart details component. + */ +export default function ChartDetails({ vanillaHelmRepo }: ChartDetailsProps) { const { chartName, repoName } = useParams<{ chartName: string; repoName: string }>(); const [chart, setChart] = useState<{ name: string; description: string; - logo_image_id: string; + logo_image_id?: string; // optional just in case readme: string; app_version: string; maintainers: Array<{ name: string; email: string }>; home_url: string; package_id: string; version: string; + icon?: string; // used when VANILLA_HELM_REPO } | null>(null); const [openEditor, setOpenEditor] = useState(false); + const chartCfg = getCatalogConfig(); useEffect(() => { // Note: This path is not enabled for vanilla helm repo. Please check the following comment in charts/List.tsx @@ -51,6 +65,7 @@ export default function ChartDetails() { handleEditor={open => { setOpenEditor(open); }} + chartProfile={vanillaHelmRepo} /> {chart.logo_image_id && ( - {CHART_PROFILE === VANILLA_HELM_REPO ? ( - {chart.name} - ) : ( - {chart.name} - )} + {chartCfg.chartProfile === vanillaHelmRepo ? ( + {chart.name} + ) : ( + {chart.name} + )} )} {chart.name} diff --git a/app-catalog/src/components/charts/EditorDialog.tsx b/app-catalog/src/components/charts/EditorDialog.tsx index 39a93ec45..27244eb6d 100644 --- a/app-catalog/src/components/charts/EditorDialog.tsx +++ b/app-catalog/src/components/charts/EditorDialog.tsx @@ -6,12 +6,12 @@ import { Autocomplete } from '@mui/material'; import _ from 'lodash'; import { useSnackbar } from 'notistack'; import { useEffect, useState } from 'react'; +import { getCatalogConfig } from '../../api/catalogConfig'; import { fetchChartDetailFromArtifact, fetchChartValues } from '../../api/charts'; import { createRelease, getActionStatus } from '../../api/releases'; import { addRepository } from '../../api/repository'; +import { APP_CATALOG_HELM_REPOSITORY } from '../../constants/catalog'; import { jsonToYAML, yamlToJSON } from '../../helpers'; -import { APP_CATALOG_HELM_REPOSITORY,VANILLA_HELM_REPO } from './List'; -//import * as global from "global"; type FieldType = { value: string; @@ -22,14 +22,15 @@ export function EditorDialog(props: { openEditor: boolean; chart: any; handleEditor: (open: boolean) => void; + chartProfile: string; }) { if (!props.chart) return null; - const { openEditor, handleEditor, chart } = props; + const { openEditor, handleEditor, chart, chartProfile } = props; const [installLoading, setInstallLoading] = useState(false); const [namespaces, error] = K8s.ResourceClasses.Namespace.useList(); const [chartValues, setChartValues] = useState(''); - const [defaultChartValues, setDefaultChartValues] = useState<{}>(''); + const [defaultChartValues, setDefaultChartValues] = useState>({}); const [chartValuesLoading, setChartValuesLoading] = useState(false); const [chartValuesFetchError, setChartValuesFetchError] = useState(null); const { enqueueSnackbar } = useSnackbar(); @@ -44,6 +45,7 @@ export function EditorDialog(props: { title: namespace.metadata.name, })); const themeName = localStorage.getItem('headlampThemePreference'); + const chartCfg = getCatalogConfig(); useEffect(() => { setIsFormSubmitting(false); @@ -55,7 +57,7 @@ export function EditorDialog(props: { } }, [selectedNamespace, namespaceNames]); - // Fetch chart values for a given package and version + // Fetch chart values for a given package and version (when chart version changed in editor dialog) function refreshChartValue(packageID: string, packageVersion: string) { fetchChartValues(packageID, packageVersion) .then((response: any) => { @@ -70,7 +72,7 @@ export function EditorDialog(props: { } function handleChartValueFetch(chart: any) { - const packageID = globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? chart.name : chart.package_id; + const packageID = chartCfg.chartProfile === chartProfile ? chart.name : chart.package_id; const packageVersion = selectedVersion?.value ?? chart.version; setChartValuesLoading(true); fetchChartValues(packageID, packageVersion) @@ -89,23 +91,33 @@ export function EditorDialog(props: { } useEffect(() => { - if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO) { - const versionsArray = AVAILABLE_VERSIONS.get(chart.name); - const availableVersions = versionsArray.map(({ version }) => ({ title: version, value: version })); - // @ts-ignore + if (chartCfg.chartProfile === chartProfile) { + const versionsArray = + AVAILABLE_VERSIONS instanceof Map && AVAILABLE_VERSIONS.get && chart.name + ? AVAILABLE_VERSIONS.get(chart.name) + : undefined; + + const availableVersions = Array.isArray(versionsArray) + ? versionsArray.map(({ version }) => ({ + title: version, + value: version, + })) + : []; setVersions(availableVersions); setChartInstallDescription(`${chart.name} deployment`); setSelectedVersion(availableVersions[0]); } else { fetchChartDetailFromArtifact(chart.name, chart.repository.name).then(response => { if (response.available_versions) { - const availableVersions = response.available_versions.map(({ version }) => ({ title: version, value: version })); + const availableVersions = response.available_versions.map(({ version }) => ({ + title: version, + value: version, + })); setVersions(availableVersions); setSelectedVersion(availableVersions[0]); } }); } - handleChartValueFetch(chart); }, [chart]); useEffect(() => { @@ -166,19 +178,21 @@ export function EditorDialog(props: { return; } - const jsonChartValues = yamlToJSON(chartValues); + const jsonChartValues = yamlToJSON(chartValues) as Record; const chartValuesDIFF = _.omitBy(jsonChartValues, (value, key) => - _.isEqual(defaultChartValues[key], value) - ); + _.isEqual((defaultChartValues as Record)[key], value) + ) as Record; setInstallLoading(true); // In case of profile: VANILLA_HELM_REPOSITORY, set the URL to access the index.yaml of the chart, as the repoURL. // During the installation of an application, this URL will be added as a chart repository, and the list of available versions // will be loaded during the upgrade from this repository. const repoURL = - globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? `${CHART_URL_PREFIX}/charts/` : chart.repository.url; + chartCfg.chartProfile === chartProfile + ? `${chartCfg.chartURLPrefix}/charts/` + : chart.repository.url; const repoName = - globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? APP_CATALOG_HELM_REPOSITORY : chart.repository.name; + chartCfg.chartProfile === chartProfile ? APP_CATALOG_HELM_REPOSITORY : chart.repository.name; addRepository(repoName, repoURL) .then(() => { createRelease( @@ -291,8 +305,7 @@ export function EditorDialog(props: { value={selectedVersion ?? versions[0]} // @ts-ignore onChange={(event, newValue: FieldType) => { - if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO && chart.version !== newValue.value) { - console.log('Time to change'); + if (chartCfg.chartProfile === chartProfile && chart.version !== newValue.value) { // Refresh values.yaml for a chart when the current version and new version differ refreshChartValue(chart.name, newValue.value); } diff --git a/app-catalog/src/components/charts/List.tsx b/app-catalog/src/components/charts/List.tsx index 87e22e020..2210e27e1 100644 --- a/app-catalog/src/components/charts/List.tsx +++ b/app-catalog/src/components/charts/List.tsx @@ -20,13 +20,12 @@ import { } from '@mui/material'; import { Autocomplete, Pagination } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; -import { yamlToJSON } from '../../helpers'; +import { getCatalogConfig } from '../../api/catalogConfig'; import { fetchChartIcon, fetchChartsFromArtifact } from '../../api/charts'; +import { PAGE_OFFSET_COUNT_FOR_CHARTS, VANILLA_HELM_REPO } from '../../constants/catalog'; import { AvailableComponentVersions } from '../../helpers/catalog'; -//import { createRelease } from '../../api/releases'; import { EditorDialog } from './EditorDialog'; import { SettingsLink } from './SettingsLink'; -//import * as global from "global"; interface AppCatalogConfig { /** @@ -38,27 +37,11 @@ interface AppCatalogConfig { export const store = new ConfigStore('app-catalog'); const useStoreConfig = store.useConfig(); -export const PAGE_OFFSET_COUNT_FOR_CHARTS = 9; - -export const VANILLA_HELM_REPO = 'VANILLA_HELM_REPOSITORY'; - -export const COMMUNITY_REPO = 'COMMUNITY_REPOSITORY'; - -// Replace the token with the URL prefix to values.yaml for a component on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml -// This is used only for the catalog provided by a vanilla Helm repository. -// For the default behavior when this token is not replaced during deployment, please take a look at the global variable CHART_VALUES_PREFIX and its -// usage in src/api/catalogs.tsx -export const CUSTOM_CHART_VALUES_PREFIX = 'CUSTOM_CHART_VALUES_PREFIX'; - -// The name of the helm repository added before installing an application, while using vanilla helm repository -export const APP_CATALOG_HELM_REPOSITORY = 'app-catalog'; - // Define a global variable to hold the available versions of the components in the catalog declare global { var AVAILABLE_VERSIONS: Map; } - interface SearchProps { search: string; setSearch: React.Dispatch>; @@ -174,6 +157,7 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { // we must have the default value here and have it imported for use in the settings tab const config = useStoreConfig(); const showOnlyVerified = config?.showOnlyVerified ?? true; + const chartCfg = getCatalogConfig(); // note: When the users changes the chartCategory or search, then we always go back to the first page useEffect(() => { @@ -187,90 +171,96 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { async function fetchData() { try { - const response = await fetchCharts(search, showOnlyVerified, chartCategory, page); - const data = await response.dataResponse.text(); - const d=yamlToJSON(data); - if (globalThis.CHART_PROFILE === VANILLA_HELM_REPO) { - setCharts(d.entries); - setChartsTotalCount(parseInt(response.total)); + const { data, total } = await fetchCharts(search, showOnlyVerified, chartCategory, page); + if (chartCfg.chartProfile === VANILLA_HELM_REPO) { + setCharts(data.entries); + setChartsTotalCount(parseInt(total)); // Capture available versions from the response and set AVAILABLE_VERSIONS - globalThis.AVAILABLE_VERSIONS = AvailableComponentVersions(d.entries); + globalThis.AVAILABLE_VERSIONS = AvailableComponentVersions(data.entries); } else { - setCharts(d.packages); - setChartsTotalCount(parseInt(response.total)); + setCharts(data.packages); + setChartsTotalCount(parseInt(total)); } } catch (err) { console.error('Error fetching charts', err); setCharts([]); } } - fetchData(); }, [page, chartCategory, search, showOnlyVerified] ); + type HelmIndex = Record; useEffect(() => { - if (charts && Object.keys(charts).length > 0) { - const fetchIcons = async () => { + if (charts && Object.keys(charts).length > 0) { + const fetchIcons = async () => { + try { + const iconUrls = {}; + // charts is a map of name -> chart[] + const list = Object.values(charts as HelmIndex).flat(); + const iconPromises = list.map(async (chart: any) => { + // const iconPromises = Object.values(charts).flatMap(chartArray => + // chartArray.map(async chart => { + const iconURL = chart.icon ?? ''; + if (iconURL === '') { + return; + } + const isURL = urlString => { + try { + new URL(urlString); + return true; + } catch (e) { + return false; + } + }; + if (isURL(iconURL)) { + // may be an external icon URL, so, just use as is + iconUrls[iconURL] = iconURL; + } else { + const fetchIcon = async () => { try { - const iconUrls = {}; - const iconPromises = Object.values(charts).flatMap(chartArray => - chartArray.map(async chart => { - const iconURL = chart.icon ?? ''; - if (iconURL === '') { - return; - } - const isURL = (urlString) => { - try { - new URL(urlString); - return true; - } catch (e) { - return false; - } - }; - if (isURL(iconURL)) { - // may be an external icon URL, so, just use as is - iconUrls[iconURL] = iconURL - } else { - const p = await fetchChartIcon(iconURL) - .then((response: any) => { - const contentType = response.headers.get('Content-Type'); - if (contentType.includes('image/svg+xml') || contentType.includes('text/xml') || contentType.includes('text/plain')) { - response.text() - .then((txt) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => reader.result - reader.onerror = reject; - iconUrls[iconURL] = `data:image/svg+xml;utf8,${encodeURIComponent(txt)}`; - }) - ); - } else if (contentType.includes('image/')) { - response.blob() - .then((blob) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => reader.result - reader.onerror = reject; - reader.readAsDataURL(blob); - iconUrls[iconURL] = URL.createObjectURL(blob); - }) - ); - } - }) - .catch(error => console.error("failed to fetch icon:", error)) - } - }) - ); - await Promise.all(iconPromises); - setIconUrls(iconUrls); + const response = await fetchChartIcon(iconURL); + const contentType = response.headers?.get('Content-Type'); + if ( + contentType.includes('image/svg+xml') || + contentType.includes('text/xml') || + contentType.includes('text/plain') + ) { + const txt = await response.text(); + const reader = new FileReader(); + await new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsText(new Blob([txt], { type: 'text/plain' })); + }); + iconUrls[iconURL] = `data:image/svg+xml;utf8,${encodeURIComponent(txt)}`; + } else if (contentType.includes('image/')) { + const blob = await response.blob(); + const reader = new FileReader(); + const result = await new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + iconUrls[iconURL] = result as string; + } } catch (error) { - console.error("Error fetching icons:", error); + console.error('failed to fetch icon:', error); } - }; - fetchIcons(); + }; + await fetchIcon(); + } + }); //end of chartArray + // ); // end of of iconPromisses + await Promise.all(iconPromises); + setIconUrls(iconUrls); + } catch (error) { + console.error('Error fetching icons:', error); } + }; + fetchIcons(); + } }, [charts]); return ( @@ -279,6 +269,7 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { openEditor={openEditor} chart={selectedChartForInstall} handleEditor={(open: boolean) => setEditorOpen(open)} + chartProfile={VANILLA_HELM_REPO} /> { - // Filter out the charts meeting the value entered for search field and display only the matching charts - Object.keys( - Object.keys(charts) - .filter(key => key.match(search)) - .reduce((obj, key) => { - return Object.assign(obj, { - [key]: charts[key], - }); - }, {}) - ).map(chartName => { - // When a chart contains multiple versions, only display the first version - return charts[chartName].slice(0, 1).map(chart => { - return ( - key.includes(search)) + .reduce((obj, key) => { + return Object.assign(obj, { + [key]: charts[key], + }); + }, {}) + ).map(chartName => { + // When a chart contains multiple versions, only display the first version + return charts[chartName].slice(0, 1).map(chart => { + return ( + - {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? ( - iconUrls[chart.icon] && ( - + {chartCfg.chartProfile === VANILLA_HELM_REPO + ? iconUrls[chart.icon] && ( + ) - ) : ( - chart.logo_image_id && ( - - ) - )} - + : chart.logo_image_id && ( + + )} + {(chart?.cncf || chart?.repository?.cncf) && ( - - - + + + )} {(chart?.official || chart?.repository?.official) && ( - - - + + + )} {chart?.repository?.verified_publisher && ( - - - + + + )} + - - {/* TODO: The app-catalog using artifacthub.io loads the details about the chart with an option to install the chart + {/* TODO: The app-catalog using artifacthub.io loads the details about the chart with an option to install the chart Fix this for vanilla helm repo */} - {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? chart.name : - ( - - {chart.name} - - )} - + {chartCfg.chartProfile === VANILLA_HELM_REPO ? ( + chart.name + ) : ( + + {chart.name} + + )} {/* If the chart.version contains v prefix, remove it */} {chart.version.startsWith('v') ? ( - {chart.version} + {chart.version} ) : ( - v{chart.version} + v{chart.version} )} {chart?.repository?.name || ''} @@ -479,32 +474,32 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { {chart?.description?.slice(0, 100)} {chart?.description?.length > 100 && ( - - - + + + )} @@ -512,28 +507,29 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { Provide Learn More link only when the chart has source When there are multiple sources for a chart, use the first source for the link, rather than using comma separated values */} - {globalThis.CHART_PROFILE === VANILLA_HELM_REPO ? ( - !chart?.sources ? ( - '' - ) : chart?.sources?.length === 1 ? ( - - Learn More - - ) : ( - - Learn More - - ) - ):( - - Learn More - - )} + {chartCfg.chartProfile === VANILLA_HELM_REPO ? ( + !chart?.sources ? ( + '' + ) : chart?.sources?.length === 1 ? ( + + Learn More + + ) : ( + + Learn More + + ) + ) : ( + + Learn More + + )} - ); - }); - })} + ); + }); + }) + } )} @@ -551,12 +547,12 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { /> )} - {globalThis.CHART_PROFILE !== VANILLA_HELM_REPO && ( - - - Powered by ArtifactHub - - + {chartCfg.chartProfile !== VANILLA_HELM_REPO && ( + + + Powered by ArtifactHub + + )} ); diff --git a/app-catalog/src/components/releases/EditorDialog.tsx b/app-catalog/src/components/releases/EditorDialog.tsx index 0f538ed8a..5c84706f9 100644 --- a/app-catalog/src/components/releases/EditorDialog.tsx +++ b/app-catalog/src/components/releases/EditorDialog.tsx @@ -15,9 +15,21 @@ import { useSnackbar } from 'notistack'; import { useEffect, useRef, useState } from 'react'; import semver from 'semver'; import { fetchChart, getActionStatus, upgradeRelease } from '../../api/releases'; +import { APP_CATALOG_HELM_REPOSITORY } from '../../constants/catalog'; import { jsonToYAML, yamlToJSON } from '../../helpers'; -import {APP_CATALOG_HELM_REPOSITORY} from "../charts/List"; +/** + * EditorDialog component for displaying and editing Helm release configurations. + * + * @param props - Component properties. + * @param props.openEditor - Whether the editor dialog is open. + * @param props.handleEditor - Callback function to handle editor dialog state. + * @param props.releaseName - Name of the Helm release. + * @param props.releaseNamespace - Namespace of the Helm release. + * @param props.release - Helm release object. + * @param props.isUpdateRelease - Whether the release is being updated. + * @param props.handleUpdate - Callback function to handle release update. + */ export function EditorDialog(props: { openEditor: boolean; handleEditor: (open: boolean) => void; @@ -61,11 +73,14 @@ export function EditorDialog(props: { let isMounted = true; if (isUpdateRelease) { - async function fetchChartVersions() { + const fetchChartVersions = async () => { let response; let error: Error | null = null; try { - const metadataName = release.chart.metadata.name === APP_CATALOG_HELM_REPOSITORY ? '/' + release.chart.metadata.name : release.chart.metadata.name; + const metadataName = + release.chart.metadata.name === APP_CATALOG_HELM_REPOSITORY + ? '/' + release.chart.metadata.name + : release.chart.metadata.name; response = await fetchChart(metadataName); } catch (err) { error = err; @@ -80,6 +95,7 @@ export function EditorDialog(props: { variant: 'error', autoHideDuration: 5000, }); + return; } setIsLoading(false); @@ -100,7 +116,7 @@ export function EditorDialog(props: { version: chart.version, })) ); - } + }; setIsLoading(true); fetchChartVersions(); diff --git a/app-catalog/src/components/releases/List.tsx b/app-catalog/src/components/releases/List.tsx index 11df6e435..6b4588a1e 100644 --- a/app-catalog/src/components/releases/List.tsx +++ b/app-catalog/src/components/releases/List.tsx @@ -10,6 +10,13 @@ import { Box } from '@mui/material'; import { useEffect, useState } from 'react'; import { listReleases } from '../../api/releases'; +/** + * ReleaseList component displays a list of installed Helm releases. + * + * @param props - Component properties. + * @param [props.fetchReleases=listReleases] - Function to fetch the list of releases. + * @returns ReleaseList component. + */ export default function ReleaseList({ fetchReleases = listReleases }) { const [releases, setReleases] = useState | null>(null); diff --git a/app-catalog/src/constants/catalog.ts b/app-catalog/src/constants/catalog.ts new file mode 100644 index 000000000..64470d782 --- /dev/null +++ b/app-catalog/src/constants/catalog.ts @@ -0,0 +1,16 @@ +export const VANILLA_HELM_REPO = 'VANILLA_HELM_REPOSITORY'; +export const COMMUNITY_REPO = 'COMMUNITY_REPOSITORY'; +// Replace the token with the URL prefix to values.yaml for a component on ${CUSTOM_CHART_VALUES_PREFIX}/${packageID}/${packageVersion}/values.yaml +// This is used only for the catalog provided by a vanilla Helm repository. +// For the default behavior when this token is not replaced during deployment, please take a look at the global variable CHART_VALUES_PREFIX and its +// usage in src/api/catalogs.tsx +export const CUSTOM_CHART_VALUES_PREFIX = 'CUSTOM_CHART_VALUES_PREFIX'; + +// The name of the helm repository added before installing an application, while using vanilla helm repository +export const APP_CATALOG_HELM_REPOSITORY = 'app-catalog'; + +export const PAGE_OFFSET_COUNT_FOR_CHARTS = 9; + +// Constants for the supported protocols +export const HELM_PROTOCOL = 'helm'; +export const ARTIFACTHUB_PROTOCOL = 'artifacthub'; diff --git a/app-catalog/src/helpers/catalog.ts b/app-catalog/src/helpers/catalog.ts index f6bd00df9..e21ab2d54 100644 --- a/app-catalog/src/helpers/catalog.ts +++ b/app-catalog/src/helpers/catalog.ts @@ -1,14 +1,16 @@ import { fetchCatalogs } from '../api/catalogs'; -const ANNOTATION_URI = 'catalog.ocne.io/uri'; -const ANNOTATION_NAME = 'catalog.ocne.io/name'; -const ANNOTATION_PROTOCOL = 'catalog.ocne.io/protocol'; -const ANNOTATION_DISPLAY_NAME = 'catalog.ocne.io/displayName'; +const ANNOTATION_URI = 'catalog.headlamp.dev/uri'; +const ANNOTATION_NAME = 'catalog.headlamp.dev/name'; +const ANNOTATION_PROTOCOL = 'catalog.headlamp.dev/protocol'; +const ANNOTATION_DISPLAY_NAME = 'catalog.headlamp.dev/displayName'; const DEFAULT_CATALOG_NAME = 'app-catalog'; -const DEFAULT_CATALOG_NAMESPACE = 'ocne-system'; +const DEFAULT_CATALOG_NAMESPACE = 'headlamp-system'; -// Catalog interface, containing information relevant to register a catalog in the sidebar +/** + * Catalog interface, containing information relevant to register a catalog in the sidebar + */ interface Catalog { name: string; displayName: string; @@ -24,6 +26,11 @@ interface ComponentVersions { } // Fetch the list of catalogs installed +/** + * Retrieves a list of catalogs installed by fetching catalog data and processing the response. + * + * @returns A promise that resolves to an array of Catalog objects. + */ export function CatalogLists() { return fetchCatalogs().then(function (response) { const catalogList: Array = new Array(); @@ -41,12 +48,17 @@ export function CatalogLists() { } let catalogDisplayName = ''; - if (ANNOTATION_DISPLAY_NAME in metadata.annotations && metadata.annotations[ANNOTATION_DISPLAY_NAME] != '') { - catalogDisplayName = metadata.annotations[ANNOTATION_DISPLAY_NAME] + if ( + ANNOTATION_DISPLAY_NAME in metadata.annotations && + metadata.annotations[ANNOTATION_DISPLAY_NAME] !== '' + ) { + catalogDisplayName = metadata.annotations[ANNOTATION_DISPLAY_NAME]; } else { - catalogDisplayName = metadata.annotations[ANNOTATION_NAME] + catalogDisplayName = + ANNOTATION_NAME in metadata.annotations ? metadata.annotations[ANNOTATION_NAME] : ''; } + // Represents a catalog with its metadata and URI. const catalog: Catalog = { name: metadata.name + '-' + metadata.namespace, // If there are 2 catalogs deployed with same name, the sidebar will be same. If we use the namespace, @@ -72,7 +84,12 @@ export function CatalogLists() { }); } -// Return a map with component as the key and an array of versions as the value +/** + * Retrieves available component versions from chart entries. + * @param chartEntries + * @param chartEntries - An object with component name as key and array of versions as values. + * @returns A map of component versions, where each key is a component name and its corresponding value is an array of available versions. + */ export function AvailableComponentVersions(chartEntries: any[]) { const compVersions = new Map(); for (const [key, value] of Object.entries(chartEntries)) { diff --git a/app-catalog/src/helpers/index.tsx b/app-catalog/src/helpers/index.tsx index fa2b8f84b..ae490ed67 100644 --- a/app-catalog/src/helpers/index.tsx +++ b/app-catalog/src/helpers/index.tsx @@ -1,6 +1,6 @@ import yaml from 'js-yaml'; -export function yamlToJSON(yamlObj: string) { +export function yamlToJSON(yamlObj: string): T { const loadedYaml = yaml.loadAll(yamlObj); const normalizedObject = {}; for (const parsedObject of loadedYaml) { @@ -12,7 +12,7 @@ export function yamlToJSON(yamlObj: string) { Object.assign(normalizedObject, parsedObject); } } - return normalizedObject; + return normalizedObject as T; } export function jsonToYAML(jsonObj: any) { diff --git a/app-catalog/src/index.tsx b/app-catalog/src/index.tsx index 1a79c8be6..18d706e13 100644 --- a/app-catalog/src/index.tsx +++ b/app-catalog/src/index.tsx @@ -3,17 +3,13 @@ import { registerRoute, registerSidebarEntry, } from '@kinvolk/headlamp-plugin/lib'; -import { - ARTIFACTHUB_PROTOCOL, - CommunityChartList, - HELM_PROTOCOL, - HelmChartList, -} from './api/catalogs'; import { AppCatalogSettings } from '../src/components/settings/AppCatalogSettings'; +import { CommunityChartList, HelmChartList } from './api/catalogs'; import ChartDetails from './components/charts/Details'; import { ChartsList } from './components/charts/List'; import ReleaseDetail from './components/releases/Detail'; import ReleaseList from './components/releases/List'; +import { ARTIFACTHUB_PROTOCOL, HELM_PROTOCOL, VANILLA_HELM_REPO } from './constants/catalog'; import { CatalogLists } from './helpers/catalog'; export function isElectron(): boolean { @@ -72,7 +68,7 @@ if (isElectron()) { label: 'Installed', }); } else { - // Iterate the list of c, to register the sidebar and the respective routes + // Iterate the list of catalogs to register the sidebar and the respective routes CatalogLists().then(chart => { for (let i = 0; i < chart.length; i++) { // Register the sidebar for Apps, with the URL pointing to the first chart returned @@ -159,7 +155,7 @@ registerRoute({ sidebar: 'Charts', name: 'Charts', exact: true, - component: () => , + component: () => , }); registerRoute({ diff --git a/app-catalog/tsconfig.json b/app-catalog/tsconfig.json index 23ef2e618..979a3fed7 100644 --- a/app-catalog/tsconfig.json +++ b/app-catalog/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "./node_modules/@kinvolk/headlamp-plugin/config/plugins-tsconfig.json", - "include": ["./src/**/*"] + "include": ["./src/**/*"], } From b8c81a11db5fcc6bf8dae1e4685908db8df7863b Mon Sep 17 00:00:00 2001 From: Murali Annamneni Date: Wed, 15 Oct 2025 14:33:10 +0000 Subject: [PATCH 3/3] app-catalog, doc: Update README to include supported labels and annotations Signed-off-by: Murali Annamneni --- app-catalog/README.md | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app-catalog/README.md b/app-catalog/README.md index 47b58086d..67f5bcea3 100644 --- a/app-catalog/README.md +++ b/app-catalog/README.md @@ -47,6 +47,70 @@ After completing these steps, you'll see the App Catalog link in the sidebar. ![Screenshot of the App Catalog link in the sidebar](https://github.com/user-attachments/assets/5ee65579-abfc-4820-bf83-bcc4e2bea0f5 "Screenshot of the App Catalog link in the sidebar") +## App Catalog supported labels and annotations +The App-Catalog plugin in Headlamp discovers and lists application catalogs by scanning Kubernetes Service resources. +To be recognized as a catalog source, the Service must include specific labels and annotations that describe how the plugin should interact with it. + +Catalogs can be either: + - External sources + - Internal in-cluster helm repositories or custom chart services + +| Label | Description | +|------------------------------------|--------------------------------------------------------------------------------| +| catalog.headlamp.dev/is-catalog | Indicates that this Service should be treated as an application catalog. | + + +| Annotaion | Description | +|----------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| catalog.headlamp.dev/name | Internal identifier for the catalog. It'll be used as displayName if `displayName` is empty. | +| catalog.headlamp.dev/protocol | Specifies the catalog API protocol. Supported values are helm (for in-cluster service, artifacthub (for external service) | +| catalog.headlamp.dev/displayName | (optional) User-friendly display name shown in UI. | +| catalog.headlamp.dev/uri | URL or endpoint used to fetch catalog data. For external catalogs, this must be a valid HTTP(S) URL. | + +### Sample external-service to access artifacthub.io +```yaml +apiVersion: v1 +kind: Service +metadata: + name: artifacthub-catalog + namespace: artifacthub + labels: + catalog.headlamp.dev/is-catalog: "" + annotations: + catalog.headlamp.dev/name: artifacthub-catalog + catalog.headlamp.dev/protocol: artifacthub + catalog.headlamp.dev/uri: https://artifacthub.io +spec: + type: ExternalName + externalName: artifacthub.io + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP +``` +### Sample in-cluster service to access catalog running in-cluster +```yaml +apiVersion: v1 +kind: Service +metadata: + name: demo-catalog + namespace: test-catalog + labels: + catalog.headlamp.dev/is-catalog: "" + annotations: + catalog.headlamp.dev/name: demo-catalog + catalog.headlamp.dev/protocol: helm + catalog.headlamp.dev/displayName: My demo catalog +spec: + type: NodePort + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP +``` + ## Contributing