diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 1ce3e56df..dad496063 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -125,3 +125,11 @@ export interface IDevWorkspaceResources { editorId: string | undefined; editorContent: string | undefined; } + +export interface IGettingStartedSample { + displayName: string; + description?: string; + icon: { base64data: string; mediatype: string }; + url: string; + tags?: Array; +} diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index 977b54026..e37bcc621 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -37,6 +37,7 @@ import { registerWebsocket } from './routes/api/websocket'; import { registerYamlResolverRoute } from './routes/api/yamlResolver'; import { registerFactoryAcceptanceRedirect } from './routes/factoryAcceptanceRedirect'; import { registerWorkspaceRedirect } from './routes/workspaceRedirect'; +import { registerGettingStartedSamplesRoutes } from './routes/api/gettingStartedSample'; export default async function buildApp(server: FastifyInstance): Promise { const cheHost = process.env.CHE_HOST as string; @@ -110,4 +111,6 @@ export default async function buildApp(server: FastifyInstance): Promise { registerDevworkspaceResourcesRoute(server); registerPersonalAccessTokenRoutes(server); + + registerGettingStartedSamplesRoutes(server); } diff --git a/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts index ee945a78e..55afd15b8 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/__tests__/index.spec.ts @@ -22,6 +22,7 @@ import { LogsApiService } from '../services/logsApi'; import { PodApiService } from '../services/podApi'; import { ServerConfigApiService } from '../services/serverConfigApi'; import { UserProfileApiService } from '../services/userProfileApi'; +import { GettingStartedSamplesApiService } from '../services/gettingStartedSamplesApi'; jest.mock('../services/devWorkspaceApi.ts'); @@ -49,5 +50,6 @@ describe('DevWorkspace client', () => { expect(client.podApi).toBeInstanceOf(PodApiService); expect(client.serverConfigApi).toBeInstanceOf(ServerConfigApiService); expect(client.userProfileApi).toBeInstanceOf(UserProfileApiService); + expect(client.gettingStartedSampleApi).toBeInstanceOf(GettingStartedSamplesApiService); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/index.ts b/packages/dashboard-backend/src/devworkspaceClient/index.ts index 34fdc2765..344282f24 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/index.ts @@ -22,7 +22,7 @@ import { PersonalAccessTokenService } from './services/personalAccessTokenApi'; import { PodApiService } from './services/podApi'; import { ServerConfigApiService } from './services/serverConfigApi'; import { UserProfileApiService } from './services/userProfileApi'; -import { IPodmanApi } from './types/index'; +import { IGettingStartedSampleApi, IPodmanApi } from './types/index'; import { IDevWorkspaceApi, IDevWorkspaceClient, @@ -36,6 +36,7 @@ import { IServerConfigApi, IUserProfileApi, } from './types'; +import { GettingStartedSamplesApiService } from './services/gettingStartedSamplesApi'; export * from './types'; @@ -89,4 +90,8 @@ export class DevWorkspaceClient implements IDevWorkspaceClient { get personalAccessTokenApi(): IPersonalAccessTokenApi { return new PersonalAccessTokenService(this.kubeConfig); } + + get gettingStartedSampleApi(): IGettingStartedSampleApi { + return new GettingStartedSamplesApiService(this.kubeConfig); + } } diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/gettingStartedSamplesApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/gettingStartedSamplesApi.spec.ts new file mode 100644 index 000000000..81529d6b8 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/gettingStartedSamplesApi.spec.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as mockClient from '@kubernetes/client-node'; +import { GettingStartedSamplesApiService } from '../gettingStartedSamplesApi'; +import { api } from '@eclipse-che/common'; + +describe('Getting Started Samples API Service', () => { + const env = process.env; + let gettingStartedSample: GettingStartedSamplesApiService; + + beforeEach(() => { + jest.resetModules(); + process.env = { + CHECLUSTER_CR_NAMESPACE: 'eclipse-che', + }; + + const { KubeConfig } = mockClient; + const kubeConfig = new KubeConfig(); + + kubeConfig.makeApiClient = jest.fn().mockImplementation(() => { + return { + listNamespacedConfigMap: () => { + return Promise.resolve({ + body: { items: [{ data: { mySample: JSON.stringify(getGettingStartedSample()) } }] }, + }); + }, + }; + }); + + gettingStartedSample = new GettingStartedSamplesApiService(kubeConfig); + }); + + afterEach(() => { + process.env = env; + jest.clearAllMocks(); + }); + + test('fetching metadata', async () => { + const res = await gettingStartedSample.list(); + expect(res).toEqual([getGettingStartedSample()]); + }); +}); + +function getGettingStartedSample(): api.IGettingStartedSample { + return { + displayName: 'Eclipse Che Dashboard', + description: 'Specifies development environment needed to develop the Eclipse Che Dashboard.', + tags: ['Eclipse Che', 'Dashboard'], + url: 'https://github.com/che-incubator/quarkus-api-example/', + icon: { + base64data: 'base64-encoded-data', + mediatype: 'image/png', + }, + }; +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/gettingStartedSamplesApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/gettingStartedSamplesApi.ts new file mode 100644 index 000000000..4808f70a9 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/gettingStartedSamplesApi.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { IGettingStartedSampleApi } from '../types'; +import * as k8s from '@kubernetes/client-node'; +import { api } from '@eclipse-che/common'; +import { CoreV1API, prepareCoreV1API } from './helpers/prepareCoreV1API'; +import { createError } from './helpers/createError'; +import { getIcon } from './helpers/getSampleIcon'; +import http from 'http'; +import { V1ConfigMapList } from '@kubernetes/client-node/dist/gen/model/v1ConfigMapList'; + +const API_ERROR_LABEL = 'CORE_V1_API_ERROR'; +const DEVFILE_METADATA_LABEL_SELECTOR = + 'app.kubernetes.io/component=getting-started-samples,app.kubernetes.io/part-of=che.eclipse.org'; + +export class GettingStartedSamplesApiService implements IGettingStartedSampleApi { + private readonly coreV1API: CoreV1API; + constructor(kubeConfig: k8s.KubeConfig) { + this.coreV1API = prepareCoreV1API(kubeConfig); + } + + private get env(): { NAMESPACE?: string } { + return { + NAMESPACE: process.env.CHECLUSTER_CR_NAMESPACE, + }; + } + + async list(): Promise> { + if (!this.env.NAMESPACE) { + console.warn('Mandatory environment variables are not defined: $CHECLUSTER_CR_NAMESPACE'); + return []; + } + + let response: { response: http.IncomingMessage; body: V1ConfigMapList }; + try { + response = await this.coreV1API.listNamespacedConfigMap( + this.env.NAMESPACE, + undefined, + undefined, + undefined, + undefined, + DEVFILE_METADATA_LABEL_SELECTOR, + ); + } catch (error) { + const additionalMessage = 'Unable to list getting started samples ConfigMap'; + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + + const samples: api.IGettingStartedSample[] = []; + + for (const cm of response.body.items) { + if (cm.data) { + for (const key in cm.data) { + try { + const sample = JSON.parse(cm.data[key]); + Array.isArray(sample) ? samples.push(...sample) : samples.push(sample); + } catch (error) { + console.error(`Failed to parse getting started samples: ${error}`); + } + } + } + } + + // Ensure icon for each sample + samples.forEach(sample => (sample.icon = getIcon(sample))); + + return samples; + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/getSampleIcon.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/getSampleIcon.ts new file mode 100644 index 000000000..71da3ae2c --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/getSampleIcon.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { api } from '@eclipse-che/common'; + +const DEFAULT_ICON = { + base64data: + 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgaGVpZ2h0PSI1ZW0iIHdpZHRoPSI1ZW0iIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4KICA8ZyBmaWxsPSIjNmE2ZTczIj4KICA8cGF0aAogICAgICBkPSJNNDg4LjYgMjUwLjJMMzkyIDIxNFYxMDUuNWMwLTE1LTkuMy0yOC40LTIzLjQtMzMuN2wtMTAwLTM3LjVjLTguMS0zLjEtMTcuMS0zLjEtMjUuMyAwbC0xMDAgMzcuNWMtMTQuMSA1LjMtMjMuNCAxOC43LTIzLjQgMzMuN1YyMTRsLTk2LjYgMzYuMkM5LjMgMjU1LjUgMCAyNjguOSAwIDI4My45VjM5NGMwIDEzLjYgNy43IDI2LjEgMTkuOSAzMi4ybDEwMCA1MGMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAzLjktNTIgMTAzLjkgNTJjMTAuMSA1LjEgMjIuMSA1LjEgMzIuMiAwbDEwMC01MGMxMi4yLTYuMSAxOS45LTE4LjYgMTkuOS0zMi4yVjI4My45YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43ek0zNTggMjE0LjhsLTg1IDMxLjl2LTY4LjJsODUtMzd2NzMuM3pNMTU0IDEwNC4xbDEwMi0zOC4yIDEwMiAzOC4ydi42bC0xMDIgNDEuNC0xMDItNDEuNHYtLjZ6bTg0IDI5MS4xbC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnptMjQwIDExMmwtODUgNDIuNXYtNzkuMWw4NS0zOC44djc1LjR6bTAtMTEybC0xMDIgNDEuNC0xMDItNDEuNHYtLjZsMTAyLTM4LjIgMTAyIDM4LjJ2LjZ6Ij48L3BhdGg+CiAgPC9nPgo8L3N2Zz4K', + mediatype: 'image/svg+xml', +}; + +export function getIcon(sample: api.IGettingStartedSample) { + if (!sample?.icon) { + return DEFAULT_ICON; + } + return sample.icon; +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts index 84387bdd7..a67789259 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/prepareCoreV1API.ts @@ -24,6 +24,7 @@ export type CoreV1API = Pick< | 'readNamespacedSecret' | 'replaceNamespacedSecret' | 'deleteNamespacedSecret' + | 'listNamespacedConfigMap' >; export function prepareCoreV1API(kc: k8s.KubeConfig): CoreV1API { @@ -47,5 +48,7 @@ export function prepareCoreV1API(kc: k8s.KubeConfig): CoreV1API { retryableExec(() => coreV1API.replaceNamespacedSecret(...args)), deleteNamespacedSecret: (...args: Parameters) => retryableExec(() => coreV1API.deleteNamespacedSecret(...args)), + listNamespacedConfigMap: (...args: Parameters) => + retryableExec(() => coreV1API.listNamespacedConfigMap(...args)), }; } diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index f3e0b0990..05ba1eb49 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -309,3 +309,10 @@ export interface IWatcherService> { */ stopWatching(): void; } + +export interface IGettingStartedSampleApi { + /** + * Reads all the Getting Started Samples ConfigMaps. + */ + list(): Promise>; +} diff --git a/packages/dashboard-backend/src/routes/api/gettingStartedSample.ts b/packages/dashboard-backend/src/routes/api/gettingStartedSample.ts new file mode 100644 index 000000000..55c4f4d9f --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/gettingStartedSample.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance } from 'fastify'; +import { baseApiPath } from '../../constants/config'; +import { getSchema } from '../../services/helpers'; +import { getDevWorkspaceClient } from './helpers/getDevWorkspaceClient'; +import { getServiceAccountToken } from './helpers/getServiceAccountToken'; + +const tags = ['Getting Started Samples']; + +export function registerGettingStartedSamplesRoutes(instance: FastifyInstance) { + instance.register(async server => { + server.get(`${baseApiPath}/getting-started-sample`, getSchema({ tags }), async () => { + const token = getServiceAccountToken(); + const { gettingStartedSampleApi } = getDevWorkspaceClient(token); + return gettingStartedSampleApi.list(); + }); + }); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx index d23cf3a23..a590d8cda 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx @@ -28,6 +28,7 @@ import DropdownEditors from '../DropdownEditors'; import { CubesIcon } from '@patternfly/react-icons'; import styles from './index.module.css'; +import { convertIconToSrc } from '../../../../services/registry/devfiles'; type Props = { metadata: che.DevfileMetaData; @@ -157,6 +158,10 @@ export class SampleCard extends React.PureComponent { 'data-testid': 'sample-card-icon', }; - return metadata.icon ? : ; + return metadata.icon ? ( + + ) : ( + + ); } } diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index a5a04a2ff..7662967cf 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -349,6 +349,16 @@ export default class Bootstrap { undefined, ); + const gettingStartedSampleURL = new URL( + '/dashboard/api/getting-started-sample', + window.location.origin, + ).href; + await requestRegistriesMetadata(gettingStartedSampleURL, false)( + this.store.dispatch, + this.store.getState, + undefined, + ); + const serverConfig = this.store.getState().dwServerConfig.config; const devfileRegistry = serverConfig.devfileRegistry; const internalDevfileRegistryUrl = serverConfig.devfileRegistryURL; diff --git a/packages/dashboard-frontend/src/services/registry/__tests__/devfiles.spec.ts b/packages/dashboard-frontend/src/services/registry/__tests__/devfiles.spec.ts index 6a4b58a63..6e3f69179 100644 --- a/packages/dashboard-frontend/src/services/registry/__tests__/devfiles.spec.ts +++ b/packages/dashboard-frontend/src/services/registry/__tests__/devfiles.spec.ts @@ -97,6 +97,31 @@ describe('fetch registry metadata', () => { expect(resolved).toEqual([metadata]); }); + describe('getting started samples', () => { + const baseUrl = 'http://this.is.my.base.url'; + + it('should fetch getting started samples', async () => { + const metadata = { + displayName: 'java-maven', + tags: ['Java'], + url: 'some-url', + icon: { mediatype: 'image/png', base64data: 'some-data' }, + } as che.DevfileMetaData; + mockFetchData.mockResolvedValue([metadata]); + + const resolved = await fetchRegistryMetadata( + `${baseUrl}/dashboard/api/getting-started-sample`, + false, + ); + + expect(mockSessionStorageServiceGet).not.toHaveBeenCalled(); + expect(mockFetchData).toHaveBeenCalledTimes(1); + expect(mockFetchData).toBeCalledWith(`${baseUrl}/dashboard/api/getting-started-sample`); + expect(mockSessionStorageServiceUpdate).not.toHaveBeenCalled(); + expect(resolved).toEqual([metadata]); + }); + }); + it('should throw an error if fetched data is not array', async () => { mockDateNow.mockReturnValue(1555555555555); mockFetchData.mockResolvedValue('foo'); diff --git a/packages/dashboard-frontend/src/services/registry/devfiles.ts b/packages/dashboard-frontend/src/services/registry/devfiles.ts index c178b7e00..44ac0636e 100644 --- a/packages/dashboard-frontend/src/services/registry/devfiles.ts +++ b/packages/dashboard-frontend/src/services/registry/devfiles.ts @@ -26,12 +26,27 @@ function createURL(url: string, baseUrl: string): URL { return new URL(url, baseUrl); } -function resolveIconUrl(metadata: che.DevfileMetaData, baseUrl: string): string { - if (!metadata.icon || metadata.icon.startsWith('http')) { - return metadata.icon; +function resolveIconUrl( + metadata: che.DevfileMetaData, + baseUrl: string, +): string | { base64data: string; mediatype: string } { + if (typeof metadata.icon === 'string') { + if (!metadata.icon || metadata.icon.startsWith('http')) { + return metadata.icon; + } + + return createURL(metadata.icon, baseUrl).href; + } + + return metadata.icon; +} + +export function convertIconToSrc(icon: che.DevfileMetaData['icon']): string { + if (typeof icon === 'string') { + return icon; } - return createURL(metadata.icon, baseUrl).href; + return 'data:' + icon.mediatype + ';base64,' + icon.base64data; } export function resolveTags( @@ -65,6 +80,12 @@ export function resolveLinks( delete metadata.links.self; } } + + if (metadata.url) { + metadata.links = { v2: metadata.url }; + delete metadata.url; + } + const resolvedLinks = {}; const linkNames = Object.keys(metadata.links); linkNames.map(linkName => { @@ -87,10 +108,14 @@ export function updateObjectLinks(object: any, baseUrl): any { } export function getRegistryIndexUrl(registryUrl: string, isExternal: boolean): URL { + registryUrl = registryUrl[registryUrl.length - 1] === '/' ? registryUrl : registryUrl + '/'; + if (isExternal) { if (new URL(registryUrl).host === 'registry.devfile.io') { return new URL('index', registryUrl); } + } else if (registryUrl.endsWith('/getting-started-sample/')) { + return new URL(registryUrl.replace(/\/$/, '')); } return new URL('devfiles/index.json', registryUrl); } @@ -99,8 +124,6 @@ export async function fetchRegistryMetadata( registryUrl: string, isExternal: boolean, ): Promise { - registryUrl = registryUrl[registryUrl.length - 1] === '/' ? registryUrl : registryUrl + '/'; - try { const registryIndexUrl = getRegistryIndexUrl(registryUrl, isExternal); if (isExternal) { diff --git a/packages/dashboard-frontend/src/typings/che.d.ts b/packages/dashboard-frontend/src/typings/che.d.ts index 2a3c32668..27d96bcb3 100755 --- a/packages/dashboard-frontend/src/typings/che.d.ts +++ b/packages/dashboard-frontend/src/typings/che.d.ts @@ -101,7 +101,7 @@ declare namespace che { description?: string; globalMemoryLimit?: string; registry?: string; - icon: string; + icon: string | { base64data: string; mediatype: string }; links: { v2?: string; devWorkspaces?: { @@ -110,6 +110,7 @@ declare namespace che { self?: string; [key: string]: any; }; + url?: string; tags: Array; }