diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 37a225908c..1ce64c1485 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -103,6 +103,37 @@ export interface IExternalDevfileRegistry { url: string; } +export interface AiToolDefinition { + /** Links this tool to an AiProviderDefinition.id, e.g. 'anthropic/claude' */ + providerId: string; + /** Version tag, e.g. 'latest' */ + tag: string; + name: string; + url: string; + /** Binary name available in PATH after injection, e.g. 'claude' */ + binary: string; + /** init: single binary copied; bundle: full runtime dir copied */ + pattern: 'init' | 'bundle'; + /** Full injector image, e.g. 'quay.io/oorel/claude-code:next' */ + injectorImage: string; + /** API key env var required by this tool, if any */ + envVarName?: string; + /** One-time setup command run in the editor container at postStart */ + setupCommand?: string; +} + +export interface AiProviderDefinition { + id: string; + name: string; + publisher: string; + description?: string; + docsUrl?: string; + /** URL to the provider's SVG icon */ + icon?: string; + /** Optional labels, e.g. ['Tech-Preview'] */ + tags?: string[]; +} + export interface IServerConfig { containerBuild: { containerBuildConfiguration?: { @@ -152,6 +183,17 @@ export interface IServerConfig { allowedSourceUrls: string[]; } +/** + * AI tool registry read from a cluster ConfigMap. + * Contains providers (who makes the tool), tools (how to inject it), + * and the default selection for new workspaces. + */ +export interface IAiRegistry { + providers: AiProviderDefinition[]; + tools: AiToolDefinition[]; + defaultAiProviders: string[]; +} + export interface IAdvancedAuthorization { allowUsers?: string[]; allowGroups?: string[]; diff --git a/packages/dashboard-backend/src/app.ts b/packages/dashboard-backend/src/app.ts index f230e534cf..c8037c0099 100644 --- a/packages/dashboard-backend/src/app.ts +++ b/packages/dashboard-backend/src/app.ts @@ -22,6 +22,8 @@ import { registerCors } from '@/plugins/cors'; import { registerStaticServer } from '@/plugins/staticServer'; import { registerSwagger } from '@/plugins/swagger'; import { registerWebSocket } from '@/plugins/webSocket'; +import { registerAiConfigRoutes } from '@/routes/api/aiConfig'; +import { registerAiRegistryRoute } from '@/routes/api/aiRegistry'; import { registerAirGapSampleRoute } from '@/routes/api/airGapSample'; import { registerBackupRoutes } from '@/routes/api/backup'; import { registerClusterConfigRoute } from '@/routes/api/clusterConfig'; @@ -143,5 +145,9 @@ export default async function buildApp(server: FastifyInstance): Promise { + let service: AiProviderKeyApiService; + + const stubCoreV1Api = { + listNamespacedSecret: () => { + return Promise.resolve({ + items: [ + { + metadata: { + name: secretName, + labels: { + [MOUNT_TO_DEVWORKSPACE_LABEL]: 'true', + [WATCH_SECRET_LABEL]: 'true', + [AI_PROVIDER_ID_LABEL]: sanitizedId, + }, + annotations: { + [MOUNT_AS_ANNOTATION]: 'env', + }, + }, + data: { + [envVarName]: Buffer.from('test-key').toString('base64'), + }, + } as V1Secret, + ], + } as V1SecretList); + }, + createNamespacedSecret: () => { + return Promise.resolve({} as V1Secret); + }, + replaceNamespacedSecret: () => { + return Promise.resolve({} as V1Secret); + }, + deleteNamespacedSecret: () => { + return Promise.resolve(undefined); + }, + } as unknown as CoreV1Api; + + const spyListNamespacedSecret = jest.spyOn(stubCoreV1Api, 'listNamespacedSecret'); + const spyCreateNamespacedSecret = jest.spyOn(stubCoreV1Api, 'createNamespacedSecret'); + const spyReplaceNamespacedSecret = jest.spyOn(stubCoreV1Api, 'replaceNamespacedSecret'); + const spyDeleteNamespacedSecret = jest.spyOn(stubCoreV1Api, 'deleteNamespacedSecret'); + + beforeEach(() => { + const { KubeConfig } = mockClient; + const kubeConfig = new KubeConfig(); + kubeConfig.makeApiClient = jest.fn().mockImplementation(_api => stubCoreV1Api); + service = new AiProviderKeyApiService(kubeConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listProviderIdsWithKey', () => { + it('should return provider IDs from labeled secrets', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ + items: [ + { + metadata: { + name: secretName, + labels: { + [MOUNT_TO_DEVWORKSPACE_LABEL]: 'true', + [WATCH_SECRET_LABEL]: 'true', + [AI_PROVIDER_ID_LABEL]: sanitizedId, + }, + }, + } as V1Secret, + ], + } as V1SecretList); + + const result = await service.listProviderIdsWithKey(namespace); + + expect(spyListNamespacedSecret).toHaveBeenCalledWith({ + namespace, + labelSelector: AI_PROVIDER_ID_LABEL, + }); + expect(result).toEqual([sanitizedId]); + }); + + it('should return empty array when no labeled secrets found', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ items: [] } as V1SecretList); + + const result = await service.listProviderIdsWithKey(namespace); + + expect(result).toEqual([]); + }); + + it('should make exactly one API call', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ items: [] } as V1SecretList); + + await service.listProviderIdsWithKey(namespace); + + expect(spyListNamespacedSecret).toHaveBeenCalledTimes(1); + }); + + it('should throw error when listing fails', async () => { + spyListNamespacedSecret.mockImplementationOnce(() => { + throw new Error('Forbidden'); + }); + + await expect(service.listProviderIdsWithKey(namespace)).rejects.toThrow( + `Unable to list AI provider keys in the namespace "${namespace}": Forbidden`, + ); + }); + }); + + describe('createOrReplace', () => { + it('should create a new secret when none exists', async () => { + await service.createOrReplace(namespace, providerId, 'new-api-key', envVarName); + + expect(spyCreateNamespacedSecret).toHaveBeenCalledWith( + expect.objectContaining({ + namespace, + body: expect.objectContaining({ + metadata: expect.objectContaining({ + name: secretName, + labels: expect.objectContaining({ + [MOUNT_TO_DEVWORKSPACE_LABEL]: 'true', + [WATCH_SECRET_LABEL]: 'true', + [AI_PROVIDER_ID_LABEL]: sanitizedId, + }), + annotations: expect.objectContaining({ + [MOUNT_AS_ANNOTATION]: 'env', + }), + }), + stringData: expect.objectContaining({ + [envVarName]: 'new-api-key', + }), + }), + }), + ); + expect(spyReplaceNamespacedSecret).not.toHaveBeenCalled(); + }); + + it('should fall back to replace when create returns 409 Conflict', async () => { + const conflictError = Object.assign(new Error('Conflict'), { + headers: {}, + body: { message: 'already exists' }, + code: 409, + }); + spyCreateNamespacedSecret.mockRejectedValueOnce(conflictError); + + await service.createOrReplace(namespace, providerId, 'new-api-key', envVarName); + + expect(spyCreateNamespacedSecret).toHaveBeenCalled(); + expect(spyReplaceNamespacedSecret).toHaveBeenCalledWith( + expect.objectContaining({ + name: secretName, + namespace, + body: expect.objectContaining({ + metadata: expect.objectContaining({ + name: secretName, + labels: expect.objectContaining({ + [MOUNT_TO_DEVWORKSPACE_LABEL]: 'true', + [WATCH_SECRET_LABEL]: 'true', + [AI_PROVIDER_ID_LABEL]: sanitizedId, + }), + annotations: expect.objectContaining({ + [MOUNT_AS_ANNOTATION]: 'env', + }), + }), + stringData: expect.objectContaining({ + [envVarName]: 'new-api-key', + }), + }), + }), + ); + }); + + it('should throw error when replace fails after 409', async () => { + const conflictError = Object.assign(new Error('Conflict'), { + headers: {}, + body: { message: 'already exists' }, + code: 409, + }); + spyCreateNamespacedSecret.mockRejectedValueOnce(conflictError); + spyReplaceNamespacedSecret.mockRejectedValueOnce(new Error('Internal Server Error')); + + await expect( + service.createOrReplace(namespace, providerId, 'key', envVarName), + ).rejects.toThrow(`Unable to replace AI provider key for "${providerId}"`); + }); + + it('should throw error when create fails with non-409 error', async () => { + spyCreateNamespacedSecret.mockRejectedValueOnce(new Error('Forbidden')); + + await expect( + service.createOrReplace(namespace, providerId, 'key', envVarName), + ).rejects.toThrow(`Unable to create AI provider key for "${providerId}"`); + }); + }); + + describe('delete', () => { + it('should find the secret by provider label and delete it', async () => { + await service.delete(namespace, providerId); + + expect(spyListNamespacedSecret).toHaveBeenCalledWith({ + namespace, + labelSelector: `${AI_PROVIDER_ID_LABEL}=${sanitizedId}`, + }); + expect(spyDeleteNamespacedSecret).toHaveBeenCalledWith({ + name: secretName, + namespace, + }); + }); + + it('should do nothing when no secret is found', async () => { + spyListNamespacedSecret.mockResolvedValueOnce({ items: [] } as V1SecretList); + + await service.delete(namespace, providerId); + + expect(spyDeleteNamespacedSecret).not.toHaveBeenCalled(); + }); + + it('should throw error when listing fails during delete', async () => { + spyListNamespacedSecret.mockImplementationOnce(() => { + throw new Error('Not Found'); + }); + + await expect(service.delete(namespace, providerId)).rejects.toThrow( + `Unable to delete AI provider key for "${providerId}" in the namespace "${namespace}": Not Found`, + ); + }); + + it('should throw error when deletion fails', async () => { + spyDeleteNamespacedSecret.mockImplementationOnce(() => { + throw new Error('Forbidden'); + }); + + await expect(service.delete(namespace, providerId)).rejects.toThrow( + `Unable to delete AI provider key for "${providerId}" in the namespace "${namespace}": Forbidden`, + ); + }); + }); +}); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts new file mode 100644 index 0000000000..2679c7f519 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Generated by AI Assistant + +import * as mockClient from '@kubernetes/client-node'; +import { CoreV1Api, V1ConfigMap, V1ConfigMapList } from '@kubernetes/client-node'; + +import { AiRegistryApiService } from '@/devworkspaceClient/services/aiRegistryApi'; +import { CoreV1API } from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { logger } from '@/utils/logger'; + +jest.mock('@/devworkspaceClient/services/helpers/retryableExec.ts'); + +jest.mock('@/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockCoreV1Api = { + listNamespacedConfigMap: jest.fn(), +}; + +jest.mock('@/devworkspaceClient/services/helpers/prepareCoreV1API', () => ({ + prepareCoreV1API: jest.fn(() => mockCoreV1Api), +})); + +const namespace = 'eclipse-che'; +const AI_REGISTRY_LABEL_SELECTOR = + 'app.kubernetes.io/component=ai-tool-registry,app.kubernetes.io/part-of=che.eclipse.org'; + +const EMPTY_REGISTRY = { + providers: [], + tools: [], + defaultAiProviders: [], +}; + +describe('AI Registry API Service', () => { + let service: AiRegistryApiService; + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, CHECLUSTER_CR_NAMESPACE: namespace }; + + const { KubeConfig } = mockClient; + const kubeConfig = new KubeConfig(); + service = new AiRegistryApiService(kubeConfig); + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + describe('get', () => { + it('should return empty registry when CHECLUSTER_CR_NAMESPACE is not set', async () => { + delete process.env.CHECLUSTER_CR_NAMESPACE; + + const result = await service.get(); + + expect(result).toEqual(EMPTY_REGISTRY); + expect(logger.warn).toHaveBeenCalledWith( + 'Mandatory environment variables are not defined: $CHECLUSTER_CR_NAMESPACE', + ); + expect(mockCoreV1Api.listNamespacedConfigMap).not.toHaveBeenCalled(); + }); + + it('should return parsed registry from ConfigMap data', async () => { + const registryData = { + providers: [{ id: 'provider1', name: 'Provider 1' }], + tools: [{ id: 'tool1', name: 'Tool 1' }], + defaultAiProviders: ['provider1'], + }; + + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify(registryData), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(mockCoreV1Api.listNamespacedConfigMap).toHaveBeenCalledWith({ + namespace, + labelSelector: AI_REGISTRY_LABEL_SELECTOR, + }); + expect(result).toEqual(registryData); + }); + + it('should return empty registry when no ConfigMaps found', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(result).toEqual(EMPTY_REGISTRY); + }); + + it('should return empty registry when ConfigMap has no data', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: undefined, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(result).toEqual(EMPTY_REGISTRY); + }); + + it('should skip ConfigMaps with undefined data', async () => { + const registryData = { + providers: [{ id: 'provider2', name: 'Provider 2' }], + tools: [{ id: 'tool2', name: 'Tool 2' }], + defaultAiProviders: ['provider2'], + }; + + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'empty-cm' }, + data: undefined, + } as V1ConfigMap, + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify(registryData), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(result).toEqual(registryData); + }); + + it('should throw error when API call fails', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockRejectedValueOnce(new Error('Forbidden')); + + await expect(service.get()).rejects.toThrow('Unable to list AI tool registry ConfigMap'); + }); + + it('should handle invalid JSON in ConfigMap data and return empty registry', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': 'not valid json{{{', + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('') }), + 'Failed to parse AI tool registry data: %s', + 'registry.json', + ); + expect(result).toEqual(EMPTY_REGISTRY); + }); + + it('should handle missing arrays in parsed data and return empty arrays', async () => { + const incompleteData = { + providers: 'not-an-array', + tools: null, + // defaultAiProviders is missing entirely + }; + + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify(incompleteData), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(result).toEqual(EMPTY_REGISTRY); + }); + }); +}); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/backupApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/backupApi.spec.ts index 9069e4eead..25a9059d3d 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/backupApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/backupApi.spec.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupStatus } from '@eclipse-che/common'; import * as k8s from '@kubernetes/client-node'; diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/aiProviderKeyApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/aiProviderKeyApi.ts new file mode 100644 index 0000000000..5e6bb8a83e --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/aiProviderKeyApi.ts @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2018-2025 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 { helpers } from '@eclipse-che/common'; +import * as k8s from '@kubernetes/client-node'; + +import { createError } from '@/devworkspaceClient/services/helpers/createError'; +import { + CoreV1API, + prepareCoreV1API, +} from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { IAiProviderKeyApi } from '@/devworkspaceClient/types'; + +const API_ERROR_LABEL = 'CORE_V1_API_ERROR'; + +const AI_PROVIDER_ID_LABEL = 'che.eclipse.org/ai-provider-id'; +const MOUNT_TO_DEVWORKSPACE_LABEL = 'controller.devfile.io/mount-to-devworkspace'; +const WATCH_SECRET_LABEL = 'controller.devfile.io/watch-secret'; +const MOUNT_AS_ANNOTATION = 'controller.devfile.io/mount-as'; + +function toSecretName(envVarName: string): string { + return 'ai-provider-' + envVarName.toLowerCase().replace(/_/g, '-'); +} + +function toSanitizedProviderId(providerId: string): string { + return providerId.replace(/[^a-zA-Z0-9._-]/g, '-'); +} + +function buildSecretLabels(providerId: string): Record { + return { + [MOUNT_TO_DEVWORKSPACE_LABEL]: 'true', + [WATCH_SECRET_LABEL]: 'true', + [AI_PROVIDER_ID_LABEL]: toSanitizedProviderId(providerId), + }; +} + +export class AiProviderKeyApiService implements IAiProviderKeyApi { + private readonly coreV1API: CoreV1API; + + constructor(kc: k8s.KubeConfig) { + this.coreV1API = prepareCoreV1API(kc); + } + + async listProviderIdsWithKey(namespace: string): Promise { + try { + const resp = await this.coreV1API.listNamespacedSecret({ + namespace, + labelSelector: AI_PROVIDER_ID_LABEL, + }); + return resp.items + .map(secret => secret.metadata?.labels?.[AI_PROVIDER_ID_LABEL]) + .filter((id): id is string => id !== undefined); + } catch (error) { + const additionalMessage = `Unable to list AI provider keys in the namespace "${namespace}"`; + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + } + + async createOrReplace( + namespace: string, + providerId: string, + apiKey: string, + envVarName: string, + ): Promise { + const secretName = toSecretName(envVarName); + const labels = buildSecretLabels(providerId); + + const secretBody: k8s.V1Secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: secretName, + namespace, + labels, + annotations: { + [MOUNT_AS_ANNOTATION]: 'env', + }, + }, + type: 'Opaque', + stringData: { + [envVarName]: apiKey, + }, + }; + + // Create-first approach: avoids TOCTOU race when two concurrent requests + // both observe "secret does not exist" and both try to create. + try { + await this.coreV1API.createNamespacedSecret({ + namespace, + body: secretBody, + }); + } catch (createError_) { + // 409 Conflict means the secret already exists — fall back to replace. + if (helpers.errors.isKubeClientError(createError_) && createError_.code === 409) { + try { + await this.coreV1API.replaceNamespacedSecret({ + name: secretName, + namespace, + body: secretBody, + }); + } catch (replaceError) { + const additionalMessage = `Unable to replace AI provider key for "${providerId}" in the namespace "${namespace}"`; + throw createError(replaceError, API_ERROR_LABEL, additionalMessage); + } + } else { + const additionalMessage = `Unable to create AI provider key for "${providerId}" in the namespace "${namespace}"`; + throw createError(createError_, API_ERROR_LABEL, additionalMessage); + } + } + } + + async delete(namespace: string, providerId: string): Promise { + const sanitizedId = toSanitizedProviderId(providerId); + try { + const resp = await this.coreV1API.listNamespacedSecret({ + namespace, + labelSelector: `${AI_PROVIDER_ID_LABEL}=${sanitizedId}`, + }); + const secretName = resp.items[0]?.metadata?.name; + if (secretName) { + await this.coreV1API.deleteNamespacedSecret({ name: secretName, namespace }); + } + } catch (error) { + const additionalMessage = `Unable to delete AI provider key for "${providerId}" in the namespace "${namespace}"`; + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts new file mode 100644 index 0000000000..92b6794d91 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; +import * as k8s from '@kubernetes/client-node'; +import { V1ConfigMapList } from '@kubernetes/client-node'; + +import { createError } from '@/devworkspaceClient/services/helpers/createError'; +import { + CoreV1API, + prepareCoreV1API, +} from '@/devworkspaceClient/services/helpers/prepareCoreV1API'; +import { IAiRegistryApi } from '@/devworkspaceClient/types'; +import { logger } from '@/utils/logger'; + +const API_ERROR_LABEL = 'CORE_V1_API_ERROR'; +const AI_REGISTRY_LABEL_SELECTOR = + 'app.kubernetes.io/component=ai-tool-registry,app.kubernetes.io/part-of=che.eclipse.org'; + +const EMPTY_REGISTRY: api.IAiRegistry = { + providers: [], + tools: [], + defaultAiProviders: [], +}; + +export class AiRegistryApiService implements IAiRegistryApi { + 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 get(): Promise { + if (!this.env.NAMESPACE) { + logger.warn('Mandatory environment variables are not defined: $CHECLUSTER_CR_NAMESPACE'); + return EMPTY_REGISTRY; + } + + let response: V1ConfigMapList; + try { + response = await this.coreV1API.listNamespacedConfigMap({ + namespace: this.env.NAMESPACE, + labelSelector: AI_REGISTRY_LABEL_SELECTOR, + }); + } catch (error) { + const additionalMessage = 'Unable to list AI tool registry ConfigMap'; + throw createError(error, API_ERROR_LABEL, additionalMessage); + } + + for (const cm of response.items) { + if (cm.data === undefined) { + continue; + } + + for (const key in cm.data) { + try { + const parsed: unknown = JSON.parse(cm.data[key]); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + continue; + } + const registry = parsed as Record; + return { + providers: Array.isArray(registry.providers) ? registry.providers : [], + tools: Array.isArray(registry.tools) ? registry.tools : [], + defaultAiProviders: Array.isArray(registry.defaultAiProviders) + ? registry.defaultAiProviders + : [], + }; + } catch (error) { + logger.error(error, 'Failed to parse AI tool registry data: %s', key); + } + } + } + + return EMPTY_REGISTRY; + } +} diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/backupApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/backupApi.ts index f4666c0a69..fa394759c9 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/backupApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/backupApi.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupConfig, diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/devWorkspaceClusterApiService.ts b/packages/dashboard-backend/src/devworkspaceClient/services/devWorkspaceClusterApiService.ts index 8796c3a654..c389a5c8a9 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/devWorkspaceClusterApiService.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/devWorkspaceClusterApiService.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { V1alpha2DevWorkspace } from '@devfile/api'; import { diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/OciRegistryClient.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/OciRegistryClient.ts index 8addfa0eab..d83c6f67c5 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/OciRegistryClient.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/OciRegistryClient.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BACKUP_ERROR_CODES } from '@eclipse-che/common'; import * as https from 'https'; diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/__tests__/OciRegistryClient.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/__tests__/OciRegistryClient.spec.ts index 6ee0775e7b..c1cd91b0de 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/__tests__/OciRegistryClient.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/helpers/externalRegistry/__tests__/OciRegistryClient.spec.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { OciRegistryClient } from '@/devworkspaceClient/services/helpers/externalRegistry/OciRegistryClient'; diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index 983490272e..af2fa20438 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -514,6 +514,8 @@ export interface IDevWorkspaceClient { sshKeysApi: IShhKeysApi; workspacePreferencesApi: IWorkspacePreferencesApi; editorsApi: IEditorsApi; + aiProviderKeyApi: IAiProviderKeyApi; + aiRegistryApi: IAiRegistryApi; } export interface IDevWorkspaceSingletonClient { @@ -578,6 +580,38 @@ export interface IShhKeysApi { delete(namespace: string, name: string): Promise; } +export interface IAiRegistryApi { + /** + * Reads the AI tool registry from a ConfigMap in the cluster. + * Returns providers, tools, and default provider selections. + */ + get(): Promise; +} + +export interface IAiProviderKeyApi { + /** + * Returns sanitized provider IDs that have a dashboard-managed key Secret + * in the namespace (identified by the che.eclipse.org/ai-provider-id label). + */ + listProviderIdsWithKey(namespace: string): Promise; + + /** + * Creates or replaces the API key Secret for the given provider in the given namespace. + * The envVarName becomes both the Secret data key and the injected env var name. + */ + createOrReplace( + namespace: string, + providerId: string, + apiKey: string, + envVarName: string, + ): Promise; + + /** + * Deletes the API key Secret for the given provider from the given namespace. + */ + delete(namespace: string, providerId: string): Promise; +} + export interface IBackupApi { /** * Get cluster-wide backup configuration diff --git a/packages/dashboard-backend/src/models/restParams.ts b/packages/dashboard-backend/src/models/restParams.ts index b84081c9a6..2e855d110e 100644 --- a/packages/dashboard-backend/src/models/restParams.ts +++ b/packages/dashboard-backend/src/models/restParams.ts @@ -70,3 +70,13 @@ export interface PersonalAccessTokenNamespacedParams extends INamespacedParams { export interface ShhKeyNamespacedParams extends INamespacedParams { name: string; } + +export interface AiProviderKeyNamespacedParams extends INamespacedParams { + toolId: string; +} + +export interface AiProviderKeyBody { + toolId: string; + envVarName: string; + apiKey: string; +} diff --git a/packages/dashboard-backend/src/routes/api/__tests__/aiConfig.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/aiConfig.spec.ts new file mode 100644 index 0000000000..324a5a66c1 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/aiConfig.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2025 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 { stubAiProviderKeyIds } from '@/routes/api/helpers/__mocks__/getDevWorkspaceClient'; +import { setup, teardown } from '@/utils/appBuilder'; + +jest.mock('../helpers/getToken.ts'); +jest.mock('../helpers/getDevWorkspaceClient.ts'); +jest.mock('../helpers/getServiceAccountToken.ts'); + +describe('AI Config Routes', () => { + let app: FastifyInstance; + const namespace = 'user-che'; + + beforeEach(async () => { + app = await setup(); + }); + + afterEach(() => { + teardown(app); + jest.clearAllMocks(); + }); + + describe('GET /api/namespace/:namespace/ai-provider-key', () => { + it('should return 200 with provider IDs', async () => { + const res = await app.inject().get(`${baseApiPath}/namespace/${namespace}/ai-provider-key`); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual(stubAiProviderKeyIds); + }); + }); + + describe('POST /api/namespace/:namespace/ai-provider-key', () => { + it('should return 201 with the toolId', async () => { + const body = { toolId: 'google/gemini', envVarName: 'GEMINI_API_KEY', apiKey: 'AIzaSy12345' }; + const res = await app + .inject() + .post(`${baseApiPath}/namespace/${namespace}/ai-provider-key`) + .payload(body); + + expect(res.statusCode).toEqual(201); + expect(res.json()).toEqual({ toolId: 'google/gemini' }); + }); + + it('should return 400 when envVarName is missing', async () => { + const res = await app + .inject() + .post(`${baseApiPath}/namespace/${namespace}/ai-provider-key`) + .payload({ toolId: 'google/gemini', apiKey: 'AIzaSy12345' }); // missing envVarName + + expect(res.statusCode).toEqual(400); + }); + + it('should return 400 when apiKey is missing', async () => { + const res = await app + .inject() + .post(`${baseApiPath}/namespace/${namespace}/ai-provider-key`) + .payload({ toolId: 'google/gemini', envVarName: 'GEMINI_API_KEY' }); // missing apiKey + + expect(res.statusCode).toEqual(400); + }); + }); + + describe('DELETE /api/namespace/:namespace/ai-provider-key/:toolId', () => { + it('should return 204 on success', async () => { + const toolId = 'google/gemini'; + const res = await app + .inject() + .delete( + `${baseApiPath}/namespace/${namespace}/ai-provider-key/${encodeURIComponent(toolId)}`, + ); + + expect(res.statusCode).toEqual(204); + }); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts new file mode 100644 index 0000000000..87498b3f31 --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { FastifyInstance } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { stubAiRegistry } from '@/routes/api/helpers/__mocks__/getDevWorkspaceClient'; +import { setup, teardown } from '@/utils/appBuilder'; + +jest.mock('../helpers/getDevWorkspaceClient.ts'); +jest.mock('../helpers/getServiceAccountToken.ts'); + +describe('AI Registry Route', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await setup(); + }); + + afterEach(() => { + teardown(app); + jest.clearAllMocks(); + }); + + test(`GET ${baseApiPath}/ai-registry`, async () => { + const res = await app.inject().get(`${baseApiPath}/ai-registry`); + + expect(res.statusCode).toEqual(200); + expect(res.json()).toEqual(stubAiRegistry); + }); +}); diff --git a/packages/dashboard-backend/src/routes/api/aiConfig.ts b/packages/dashboard-backend/src/routes/api/aiConfig.ts new file mode 100644 index 0000000000..43fc837fff --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/aiConfig.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2018-2025 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, FastifyReply, FastifyRequest } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { + aiProviderKeyBodySchema, + aiProviderKeyParamsSchema, + namespacedSchema, +} from '@/constants/schemas'; +import { restParams } from '@/models'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { getToken } from '@/routes/api/helpers/getToken'; +import { getSchema } from '@/services/helpers'; + +const tags = ['AI Config']; +const rateLimitConfig = { + config: { + rateLimit: { + max: 100, + timeWindow: '1 minute', + }, + }, +}; + +export function registerAiConfigRoutes(instance: FastifyInstance) { + instance.register(async server => { + /** + * GET /dashboard/api/namespace/:namespace/ai-provider-key + * Returns sanitized provider IDs that have a dashboard-managed key Secret + * (identified by the che.eclipse.org/ai-provider-id label) in the namespace. + * Uses user bearer token. + */ + server.get( + `${baseApiPath}/namespace/:namespace/ai-provider-key`, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: namespacedSchema })), + async function (request: FastifyRequest) { + const { namespace } = request.params as restParams.INamespacedParams; + const token = getToken(request); + const { aiProviderKeyApi } = getDevWorkspaceClient(token); + return aiProviderKeyApi.listProviderIdsWithKey(namespace); + }, + ); + + /** + * POST /dashboard/api/namespace/:namespace/ai-provider-key + * Creates or replaces the API key Secret for a provider. + * The client supplies envVarName (e.g. GEMINI_API_KEY) which it already + * knows from the tool registry — no server-side CR lookup needed. + * Uses user bearer token. + */ + server.post( + `${baseApiPath}/namespace/:namespace/ai-provider-key`, + Object.assign( + {}, + rateLimitConfig, + getSchema({ tags, params: namespacedSchema, body: aiProviderKeyBodySchema }), + ), + async function (request: FastifyRequest, reply: FastifyReply) { + const { namespace } = request.params as restParams.INamespacedParams; + const { toolId, envVarName, apiKey } = request.body as restParams.AiProviderKeyBody; + const token = getToken(request); + const { aiProviderKeyApi } = getDevWorkspaceClient(token); + await aiProviderKeyApi.createOrReplace(namespace, toolId, apiKey, envVarName); + reply.code(201).send({ toolId }); + }, + ); + + /** + * DELETE /dashboard/api/namespace/:namespace/ai-provider-key/:toolId + * Deletes the API key Secret for the given provider. + * Uses user bearer token. + */ + server.delete( + `${baseApiPath}/namespace/:namespace/ai-provider-key/:toolId`, + Object.assign({}, rateLimitConfig, getSchema({ tags, params: aiProviderKeyParamsSchema })), + async function (request: FastifyRequest, reply: FastifyReply) { + const { namespace, toolId } = request.params as restParams.AiProviderKeyNamespacedParams; + const token = getToken(request); + const { aiProviderKeyApi } = getDevWorkspaceClient(token); + await aiProviderKeyApi.delete(namespace, toolId); + reply.code(204).send(); + }, + ); + }); +} diff --git a/packages/dashboard-backend/src/routes/api/aiRegistry.ts b/packages/dashboard-backend/src/routes/api/aiRegistry.ts new file mode 100644 index 0000000000..d11d19eb6f --- /dev/null +++ b/packages/dashboard-backend/src/routes/api/aiRegistry.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; +import { FastifyInstance } from 'fastify'; + +import { baseApiPath } from '@/constants/config'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; +import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; +import { getSchema } from '@/services/helpers'; + +const tags = ['AI Registry']; + +const EMPTY_REGISTRY: api.IAiRegistry = { + providers: [], + tools: [], + defaultAiProviders: [], +}; + +export function registerAiRegistryRoute(isLocalRun: boolean, instance: FastifyInstance) { + instance.register(async server => { + server.get(`${baseApiPath}/ai-registry`, getSchema({ tags }), async () => { + if (isLocalRun) { + return EMPTY_REGISTRY; + } + const token = getServiceAccountToken(); + const { aiRegistryApi } = getDevWorkspaceClient(token); + return aiRegistryApi.get(); + }); + }); +} diff --git a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts index 0581f8a0ca..e8357cf5f9 100644 --- a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts +++ b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts @@ -23,7 +23,10 @@ import * as k8s from '@kubernetes/client-node'; import { IncomingHttpHeaders } from 'http'; import { + CheClusterCustomResource, DevWorkspaceClient, + IAiProviderKeyApi, + IAiRegistryApi, IDevWorkspaceApi, IDevWorkspaceClusterApi, IDevWorkspaceTemplateApi, @@ -159,6 +162,31 @@ export const stubSshKeysList: api.SshKey[] = [ }, ]; +export const stubAiProviderKeyIds: string[] = ['google-gemini']; + +export const stubAiRegistry = { + providers: [ + { + id: 'google/gemini', + name: 'Gemini', + publisher: 'Google', + }, + ], + tools: [ + { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/oorel/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', + }, + ], + defaultAiProviders: ['google/gemini'], +}; + export const stubAutoProvision = true; export const stubAdvancedAuthorization = {}; @@ -180,31 +208,32 @@ export const getDevWorkspaceClient = jest.fn( (..._args: Parameters): ReturnType => { return { serverConfigApi: { - fetchCheCustomResource: () => ({}), - getDashboardWarning: _cheCustomResource => stubDashboardWarning, - getContainerBuild: _cheCustomResource => stubContainerBuild, - getContainerRun: _cheCustomResource => stubContainerRun, - getDefaultComponents: _cheCustomResource => stubDefaultComponents, - getDefaultEditor: _cheCustomResource => stubDefaultEditor, - getDefaultPlugins: _cheCustomResource => stubDefaultPlugins, - getPluginRegistry: _cheCustomResource => stubPluginRegistry, - getPvcStrategy: _cheCustomResource => stubPvcStrategy, - getRunningWorkspacesLimit: _cheCustomResource => stubRunningWorkspacesLimit, - getAllWorkspacesLimit: _cheCustomResource => stubAllWorkspacesLimit, + fetchCheCustomResource: () => Promise.resolve({} as CheClusterCustomResource), + getDashboardWarning: () => stubDashboardWarning, + getContainerBuild: () => stubContainerBuild, + getContainerRun: () => stubContainerRun, + getDefaultComponents: () => stubDefaultComponents, + getDefaultEditor: () => stubDefaultEditor, + getDefaultPlugins: () => stubDefaultPlugins, + getPluginRegistry: () => stubPluginRegistry, + getPvcStrategy: () => stubPvcStrategy, + getRunningWorkspacesLimit: () => stubRunningWorkspacesLimit, + getRunningWorkspacesClusterLimit: () => -1, + getAllWorkspacesLimit: () => stubAllWorkspacesLimit, getCurrentArchitecture: () => Promise.resolve(stubCurrentArchitecture), - getWorkspaceInactivityTimeout: _cheCustomResource => stubWorkspaceInactivityTimeout, - getWorkspaceRunTimeout: _cheCustomResource => stubWorkspaceRunTimeout, - getWorkspaceStartTimeout: _cheCustomResource => stubWorkspaceStartupTimeout, + getWorkspaceInactivityTimeout: () => stubWorkspaceInactivityTimeout, + getWorkspaceRunTimeout: () => stubWorkspaceRunTimeout, + getWorkspaceStartTimeout: () => stubWorkspaceStartupTimeout, getAxiosRequestTimeout: () => stubAxiosRequestTimeout, - getDefaultPluginRegistryUrl: _cheCustomResource => defaultPluginRegistryUrl, - getExternalDevfileRegistries: _cheCustomResource => externalDevfileRegistries, - getInternalRegistryDisableStatus: _cheCustomResource => internalRegistryDisableStatus, - getDashboardLogo: _cheCustomResource => dashboardLogo, - getAutoProvision: _cheCustomResource => stubAutoProvision, - getAdvancedAuthorization: _cheCustomResource => stubAdvancedAuthorization, - getAllowedSourceUrls: _cheCustomResource => stubAllowedSourceUrls, - getShowDeprecatedEditors: _cheCustomResource => stubShowDeprecatedEditors, - getHideEditorsById: _cheCustomResource => stubHideEditorsById, + getDefaultPluginRegistryUrl: () => defaultPluginRegistryUrl, + getExternalDevfileRegistries: () => externalDevfileRegistries, + getInternalRegistryDisableStatus: () => internalRegistryDisableStatus, + getDashboardLogo: () => dashboardLogo, + getAutoProvision: () => stubAutoProvision, + getAdvancedAuthorization: () => stubAdvancedAuthorization, + getAllowedSourceUrls: () => stubAllowedSourceUrls, + getShowDeprecatedEditors: () => stubShowDeprecatedEditors, + getHideEditorsById: () => stubHideEditorsById, } as IServerConfigApi, devworkspaceApi: { create: (_devworkspace, _namespace) => @@ -281,6 +310,14 @@ export const getDevWorkspaceClient = jest.fn( removeProviderFromSkipAuthorizationList: (_namespace, _provider) => Promise.resolve(), removeTrustedSources: _namespace => Promise.resolve(), } as IWorkspacePreferencesApi, + aiProviderKeyApi: { + listProviderIdsWithKey: _namespace => Promise.resolve(stubAiProviderKeyIds), + createOrReplace: (_namespace, _providerId, _apiKey, _envVarName) => Promise.resolve(), + delete: (_namespace, _providerId) => Promise.resolve(), + } as IAiProviderKeyApi, + aiRegistryApi: { + get: () => Promise.resolve(stubAiRegistry), + } as IAiRegistryApi, } as DevWorkspaceClient; }, ); diff --git a/packages/dashboard-backend/src/routes/api/serverConfig.ts b/packages/dashboard-backend/src/routes/api/serverConfig.ts index dd4d7c7798..9fcce58205 100644 --- a/packages/dashboard-backend/src/routes/api/serverConfig.ts +++ b/packages/dashboard-backend/src/routes/api/serverConfig.ts @@ -52,7 +52,6 @@ export function registerServerConfigRoute(instance: FastifyInstance) { const axiosRequestTimeout = serverConfigApi.getAxiosRequestTimeout(); const showDeprecated = serverConfigApi.getShowDeprecatedEditors(cheCustomResource); const hideById = serverConfigApi.getHideEditorsById(cheCustomResource); - const serverConfig: api.IServerConfig = { containerBuild, containerRun, diff --git a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx index 464d4200af..f5422519ab 100644 --- a/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx +++ b/packages/dashboard-frontend/src/__tests__/workspaceCreationTimeCheck.check.tsx @@ -115,6 +115,11 @@ describe('Workspace creation time', () => { return responseWithDelay([], REQUEST_TIME_200); case 'http://localhost/dashboard/api/airgap-sample': return responseWithDelay([], REQUEST_TIME_200); + case '/dashboard/api/ai-registry': + return responseWithDelay( + { providers: [], tools: [], defaultAiProviders: [] }, + REQUEST_TIME_200, + ); case 'http://localhost/dashboard/devfile-registry/devfiles/empty.yaml': return responseWithDelay('', REQUEST_TIME_200); default: diff --git a/packages/dashboard-frontend/src/components/AiSelector/DocsLink/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/DocsLink/__tests__/index.spec.tsx new file mode 100644 index 0000000000..aa57190ab8 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/DocsLink/__tests__/index.spec.tsx @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AiSelectorDocsLink } from '@/components/AiSelector/DocsLink'; + +describe('AiSelectorDocsLink', () => { + it('should render with default docs URL', () => { + render(); + + const link = screen.getByRole('link', { name: /learn more about ai providers/i }); + expect(link).toHaveAttribute( + 'href', + 'https://eclipse.dev/che/docs/stable/end-user-guide/ai-provider/', + ); + }); + + it('should render with custom docs URL', () => { + render(); + + const link = screen.getByRole('link', { name: /learn more about ai providers/i }); + expect(link).toHaveAttribute('href', 'https://example.com/docs'); + }); +}); diff --git a/packages/dashboard-frontend/src/components/AiSelector/DocsLink/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/DocsLink/index.tsx new file mode 100644 index 0000000000..21a10ff8c0 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/DocsLink/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018-2025 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 { Button, Flex, FlexItem } from '@patternfly/react-core'; +import React from 'react'; + +export type Props = { + docsUrl?: string; +}; + +const DEFAULT_DOCS_URL = 'https://eclipse.dev/che/docs/stable/end-user-guide/ai-provider/'; + +export class AiSelectorDocsLink extends React.PureComponent { + public render() { + const { docsUrl } = this.props; + const href = docsUrl || DEFAULT_DOCS_URL; + return ( + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9eea72bcb2 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/__tests__/index.spec.tsx @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AiSelectorErrorBoundary } from '@/components/AiSelector/ErrorBoundary'; + +function ProblemChild(): React.ReactElement { + throw new Error('test error'); +} + +describe('AiSelectorErrorBoundary', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should render children when no error', () => { + render( + + child content + , + ); + + expect(screen.getByText('child content')).toBeDefined(); + }); + + it('should render error alert when child throws', () => { + render( + + + , + ); + + expect(screen.getByText('AI Provider Selector unavailable')).toBeDefined(); + expect(screen.getByText(/The AI provider selector failed to load/)).toBeDefined(); + }); +}); diff --git a/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/index.tsx new file mode 100644 index 0000000000..f2681ed8a4 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/ErrorBoundary/index.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025 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 { Alert, AlertVariant } from '@patternfly/react-core'; +import React, { ErrorInfo, PropsWithChildren } from 'react'; + +type State = { + hasError: boolean; + errorMessage: string | undefined; +}; + +export class AiSelectorErrorBoundary extends React.PureComponent { + constructor(props: PropsWithChildren) { + super(props); + this.state = { hasError: false, errorMessage: undefined }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, errorMessage: error.message }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('AiSelector rendering error:', error, errorInfo); + } + + public render(): React.ReactNode { + if (this.state.hasError) { + return ( + + The AI provider selector failed to load. You can still create a workspace without + selecting an AI provider. + + ); + } + return this.props.children; + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx new file mode 100644 index 0000000000..5ae2bfbe0c --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2018-2025 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'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AiProviderEntry } from '@/components/AiSelector/Gallery/Entry'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +const { renderComponent } = getComponentRenderer(getComponent); + +const mockOnSelect = jest.fn(); + +const geminiProvider: api.AiToolDefinition = { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle' as const, + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('AiProviderEntry', () => { + it('renders provider name', () => { + renderComponent(geminiProvider, false, false); + expect(screen.getByText('Gemini')).toBeInTheDocument(); + }); + + it('calls onSelect when the card is clicked and not already selected', async () => { + renderComponent(geminiProvider, false, false); + const card = screen.getByText('Gemini').closest('[class*="pf-v6-c-card"]'); + await userEvent.click(card!); + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); + }); + + it('calls onToggle when already selected (to deselect)', async () => { + renderComponent(geminiProvider, true, false); + const card = screen.getByText('Gemini').closest('[class*="pf-v6-c-card"]'); + await userEvent.click(card!); + expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); + }); + + it('shows "Key configured" badge when key exists', () => { + renderComponent(geminiProvider, false, true); + expect(screen.getByText(/Key configured/i)).toBeInTheDocument(); + }); + + it('does not show key badge when no key exists', () => { + renderComponent(geminiProvider, false, false); + expect(screen.queryByText(/Key configured/i)).toBeNull(); + }); + + it('shows Tech-Preview badge when provider has Tech-Preview tag', () => { + renderComponent(geminiProvider, false, false, ['Tech-Preview']); + expect(screen.getByText('Tech-Preview')).toBeInTheDocument(); + }); + + it('does not show Tech-Preview badge when provider has no tags', () => { + renderComponent(geminiProvider, false, false); + expect(screen.queryByText('Tech-Preview')).toBeNull(); + }); +}); + +function getComponent( + provider: api.AiToolDefinition, + isSelected: boolean, + hasExistingKey: boolean, + tags?: string[], +): React.ReactElement { + return ( + + ); +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css new file mode 100644 index 0000000000..abe3689d5a --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +.activeCard { + font-weight: 600; +} + +.providerIcon { + position: relative; + bottom: 1px; + + width: 20px; + height: 20px; + margin-right: 6px; + padding: 1px; + + vertical-align: middle; + + border-radius: 3px; +} + +:global(html.pf-v6-theme-dark) .providerIcon { + background-color: var(--pf-t--color--gray--10); +} + +.description { + font-size: 75%; +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx new file mode 100644 index 0000000000..0e961ea494 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2018-2025 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'; +import { Badge, Card, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import styles from '@/components/AiSelector/Gallery/Entry/index.module.css'; + +export type Props = { + provider: api.AiToolDefinition; + icon?: string; + description?: string; + tags?: string[]; + isSelected: boolean; + hasExistingKey: boolean; + onToggle: (providerId: string) => void; +}; + +export class AiProviderEntry extends React.PureComponent { + private get cardId(): string { + return `ai-provider-card-${this.props.provider.providerId.replace(/\//g, '-')}`; + } + + private get selectableActionId(): string { + return `ai-provider-input-${this.props.provider.providerId.replace(/\//g, '-')}`; + } + + private handleToggle = (): void => { + this.props.onToggle(this.props.provider.providerId); + }; + + private handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleToggle(); + return; + } + + const card = event.currentTarget; + const gallery = card.parentElement; + if (!gallery) { + return; + } + + const cards = Array.from(gallery.querySelectorAll('[id^="ai-provider-card-"]')); + const currentIndex = cards.indexOf(card); + if (currentIndex === -1) { + return; + } + + let nextIndex = -1; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + nextIndex = (currentIndex + 1) % cards.length; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + nextIndex = (currentIndex - 1 + cards.length) % cards.length; + } + + if (nextIndex !== -1) { + event.preventDefault(); + cards[nextIndex].focus(); + } + }; + + private getTags(): React.ReactElement[] { + const { tags } = this.props; + if (!tags) { + return []; + } + return tags + .filter(tag => tag === 'Tech-Preview') + .map((tag, index) => ( + + {tag} + + )); + } + + public render(): React.ReactElement { + const { provider, icon, description, isSelected, hasExistingKey } = this.props; + + const titleClassName = isSelected ? styles.activeCard : ''; + const tagBadges = this.getTags(); + + return ( + + 0 ? { actions: <>{tagBadges} } : undefined} + > + + {icon && ( + {`${provider.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} + {provider.name} + {provider.envVarName && hasExistingKey && ( + + Key configured + + )} + + + {description && ( + +
{description}
+
+ )} +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__mocks__/index.tsx new file mode 100644 index 0000000000..764a494bbb --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__mocks__/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2025 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 React from 'react'; + +import { Props } from '@/components/AiSelector/Gallery'; + +export class AiProviderGallery extends React.PureComponent { + public render() { + const { selectedProviderIds, onToggle } = this.props; + + return ( +
+
AI Provider Gallery
+
{selectedProviderIds.join(',')}
+ +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 0000000000..d64a34a1e8 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,134 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AiProviderGallery snapshot 1`] = ` +
+
+
+
+
+
+ +
+
+
+
+
+
+ Claude +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Gemini +
+
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx new file mode 100644 index 0000000000..77f1c95070 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2018-2025 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'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AiProviderGallery } from '@/components/AiSelector/Gallery'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnSelect = jest.fn(); + +const mockProviders: api.AiToolDefinition[] = [ + { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle' as const, + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', + }, + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude', + url: 'https://claude.ai/code', + binary: 'claude', + pattern: 'init' as const, + injectorImage: 'quay.io/example/claude-code:next', + envVarName: 'ANTHROPIC_API_KEY', + }, +]; + +describe('AiProviderGallery', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('renders all provider cards', () => { + renderComponent(); + + expect(screen.getByText('Gemini')).toBeInTheDocument(); + expect(screen.getByText('Claude')).toBeInTheDocument(); + }); + + test('clicking a provider card calls onSelect', async () => { + renderComponent(); + + // Click the checkbox input for the first provider (Gemini) + const geminiInput = screen.getByRole('checkbox', { name: /Gemini/i }); + await userEvent.click(geminiInput); + + expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); + }); + + test('shows "Key configured" badge when provider has an existing key', () => { + renderComponent('google/gemini', { 'google/gemini': true }); + + expect(screen.getByText(/Key configured/i)).toBeInTheDocument(); + }); +}); + +function getComponent(selectedProviderId?: string, providerKeyExists?: Record) { + return ( + + ); +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/index.tsx new file mode 100644 index 0000000000..daefe21463 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2025 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'; +import { Gallery } from '@patternfly/react-core'; +import React from 'react'; + +import { AiProviderEntry } from '@/components/AiSelector/Gallery/Entry'; + +export type Props = { + providers: api.AiToolDefinition[]; + aiProviders: api.AiProviderDefinition[]; + selectedProviderIds: string[]; + providerKeyExists: Record; + onToggle: (providerId: string) => void; +}; + +export class AiProviderGallery extends React.PureComponent { + private getProvider(tool: api.AiToolDefinition): api.AiProviderDefinition | undefined { + return this.props.aiProviders.find(p => p.id === tool.providerId); + } + + public render(): React.ReactElement { + const { providers, selectedProviderIds, providerKeyExists, onToggle } = this.props; + + const sorted = [...providers].sort((a, b) => a.name.localeCompare(b.name)); + + return ( + + {sorted.map(provider => { + const providerDef = this.getProvider(provider); + return ( + + ); + })} + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/__mocks__/index.tsx new file mode 100644 index 0000000000..a429bb8a6d --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2025 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 React from 'react'; + +import { Props } from '@/components/AiSelector'; + +export default class AiSelector extends React.PureComponent { + render() { + const { onSelect } = this.props; + return ( +
+ AI Selector + + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/AiSelector/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 0000000000..e5cdeb8ebc --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,247 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AiSelector snapshot 1`] = ` +
+
+

+ AI Provider Selector +

+
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/components/AiSelector/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/__tests__/index.spec.tsx new file mode 100644 index 0000000000..2ad58b0083 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/__tests__/index.spec.tsx @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2018-2025 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'; +import { StateMock } from '@react-mock/state'; +import { configureStore } from '@reduxjs/toolkit'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import AiSelector, { State } from '@/components/AiSelector'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { rootReducer } from '@/store/rootReducer'; + +jest.mock('@/components/AiSelector/Gallery'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnSelect = jest.fn(); + +const mockTools: api.AiToolDefinition[] = [ + { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle' as const, + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', + }, + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai/code', + binary: 'claude', + pattern: 'init' as const, + injectorImage: 'quay.io/example/claude-code:next', + envVarName: 'ANTHROPIC_API_KEY', + }, +]; + +describe('AiSelector', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('renders panel with accordion when providers exist', () => { + renderComponent(); + + expect(screen.getByText('AI Provider Selector')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use a Default AI Provider' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Choose an AI Provider' })).toBeInTheDocument(); + }); + + test('initially shows "Use a Default AI Provider" section', () => { + renderComponent(); + + expect(screen.getByTestId('no-ai-provider-content')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('ai-provider-gallery-content')).toHaveAttribute('hidden'); + }); + + test('accordion content toggling', async () => { + renderComponent(); + + const noAiButton = screen.getByRole('button', { name: 'Use a Default AI Provider' }); + const chooseAiButton = screen.getByRole('button', { name: 'Choose an AI Provider' }); + + // Initially the "Use a Default AI Provider" section is visible + expect(screen.getByTestId('no-ai-provider-content')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('ai-provider-gallery-content')).toHaveAttribute('hidden'); + + // Switch to "Choose an AI Provider" section + await userEvent.click(chooseAiButton); + expect(screen.getByTestId('ai-provider-gallery-content')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('no-ai-provider-content')).toHaveAttribute('hidden'); + + // Switch back to "Use a Default AI Provider" + await userEvent.click(noAiButton); + expect(screen.getByTestId('no-ai-provider-content')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('ai-provider-gallery-content')).toHaveAttribute('hidden'); + }); + + test('calls onSelect with default tool IDs when switching back to "Use a Default AI Provider"', async () => { + renderComponent(); + + // First switch to "Choose an AI Provider" + const chooseAiButton = screen.getByRole('button', { name: 'Choose an AI Provider' }); + await userEvent.click(chooseAiButton); + mockOnSelect.mockClear(); + + // Now click "Use a Default AI Provider" — should fire onSelect with the default tools + const noAiButton = screen.getByRole('button', { name: 'Use a Default AI Provider' }); + await userEvent.click(noAiButton); + + // Falls back to alphabetically first tool since no defaultProviderIds is set in Redux + expect(mockOnSelect).toHaveBeenCalledWith(['anthropic/claude']); + }); + + test('toggle provider from gallery', async () => { + renderComponent({ + selectedProviderIds: ['google/gemini'], + expandedId: 'selector', + }); + + const galleryContent = screen.getByTestId('ai-provider-gallery-content'); + expect(galleryContent).not.toHaveAttribute('hidden'); + + // The mocked gallery renders a "Toggle Provider" button + const toggleButton = screen.getByRole('button', { name: 'Toggle Provider' }); + await userEvent.click(toggleButton); + + // google/gemini was already selected, toggling removes it + expect(mockOnSelect).toHaveBeenCalledWith([]); + }); + + test('returns null when no AI providers are configured', () => { + const emptyStore = buildStoreWithTools([]); + const result = render( + + + , + ); + expect(result.baseElement.querySelector('[class*="pf-v6-c-panel"]')).toBeNull(); + }); + + test('does not fire onSelect when clicking already-open accordion item', async () => { + renderComponent(); + + // componentDidMount fires onSelect with the default tools + expect(mockOnSelect).toHaveBeenCalledWith(['anthropic/claude']); + mockOnSelect.mockClear(); + + // "Use a Default AI Provider" is open by default — clicking it again should not fire onSelect + const noAiButton = screen.getByRole('button', { name: 'Use a Default AI Provider' }); + await userEvent.click(noAiButton); + await userEvent.click(noAiButton); + + expect(mockOnSelect).not.toHaveBeenCalled(); + }); +}); + +function buildStoreWithTools(tools: api.AiToolDefinition[]) { + return configureStore({ + reducer: rootReducer, + preloadedState: { + aiConfig: { + providers: [], + tools, + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, + }, + } as Parameters[0]['preloadedState'], + }); +} + +function getComponent(localState?: Partial) { + const store = buildStoreWithTools(mockTools); + + const component = ; + + if (localState) { + return ( + + {component} + + ); + } + + return {component}; +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/index.tsx new file mode 100644 index 0000000000..03a5022d41 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiSelector/index.tsx @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2018-2025 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 { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + Button, + Content, + Panel, + PanelHeader, + PanelMain, + PanelMainBody, + Title, +} from '@patternfly/react-core'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { AiSelectorDocsLink } from '@/components/AiSelector/DocsLink'; +import { AiProviderGallery } from '@/components/AiSelector/Gallery'; +import { ROUTE } from '@/Routes'; +import { UserPreferencesTab } from '@/services/helpers/types'; +import { RootState } from '@/store'; +import { + selectAiProviderKeyExists, + selectAiProviders, + selectAiTools, + selectDefaultAiProviders, +} from '@/store/AiConfig'; + +type AccordionId = 'none' | 'selector'; + +export type Props = MappedProps & { + onSelect: (providerIds: string[]) => void; +}; + +export type State = { + selectedProviderIds: string[]; + expandedId: AccordionId | undefined; +}; + +class AiSelector extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + selectedProviderIds: [], + expandedId: 'none', + }; + } + + public componentDidMount(): void { + this.preselectDefaultTools(); + + // If tools are already loaded (from bootstrap), notify parent of defaults + const defaultToolIds = this.findDefaultToolIds(); + if (defaultToolIds.length > 0) { + this.props.onSelect(defaultToolIds); + } + } + + public componentDidUpdate(prevProps: Props): void { + if ( + prevProps.aiTools !== this.props.aiTools || + prevProps.defaultProviderIds !== this.props.defaultProviderIds + ) { + this.preselectDefaultTools(); + + // When "Use Default AI Providers" is active, notify parent + if (this.state.expandedId === 'none') { + const defaultToolIds = this.findDefaultToolIds(); + this.props.onSelect(defaultToolIds); + } + } + } + + private findDefaultToolIds(): string[] { + const { aiTools, defaultProviderIds } = this.props; + if (aiTools.length === 0) { + return []; + } + if (defaultProviderIds.length > 0) { + return defaultProviderIds.filter(id => aiTools.some(t => t.providerId === id)); + } + // Fallback: first tool alphabetically + const first = [...aiTools].sort((a, b) => a.name.localeCompare(b.name))[0]; + return first ? [first.providerId] : []; + } + + private preselectDefaultTools(): void { + if (this.state.selectedProviderIds.length > 0) { + return; + } + const toolIds = this.findDefaultToolIds(); + if (toolIds.length > 0) { + this.setState({ selectedProviderIds: toolIds }); + } + } + + private handleToggle(expandedId: AccordionId): void { + const { onSelect } = this.props; + + if (this.state.expandedId === expandedId) { + return; + } + + if (expandedId === 'none') { + // "Use Default AI Providers" — reset selections to defaults + const defaultToolIds = this.findDefaultToolIds(); + this.setState({ expandedId, selectedProviderIds: defaultToolIds }); + onSelect(defaultToolIds); + } else { + this.setState({ expandedId }); + onSelect(this.state.selectedProviderIds); + } + } + + private handleProviderToggle(providerId: string): void { + this.setState( + prevState => { + const isSelected = prevState.selectedProviderIds.includes(providerId); + const selectedProviderIds = isSelected + ? prevState.selectedProviderIds.filter(id => id !== providerId) + : [...prevState.selectedProviderIds, providerId]; + return { selectedProviderIds }; + }, + () => { + if (this.state.expandedId === 'selector') { + this.props.onSelect(this.state.selectedProviderIds); + } + }, + ); + } + + private buildDefaultProviderMessage(): string { + const { aiProviders, defaultProviderIds } = this.props; + const names = defaultProviderIds + .map(id => aiProviders.find(p => p.id === id)?.name) + .filter((name): name is string => name !== undefined); + + if (names.length === 0) { + return 'The default AI provider configured by your administrator will be used.'; + } + if (names.length === 1) { + return `The default AI provider "${names[0]}" configured by your administrator will be used.`; + } + const quoted = names.map(n => `"${n}"`).join(', '); + return `The default AI providers ${quoted} configured by your administrator will be used.`; + } + + public render(): React.ReactElement | null { + const { aiProviders, aiTools, providerKeyExists } = this.props; + const { expandedId, selectedProviderIds } = this.state; + + if (aiTools.length === 0) { + return null; + } + + const manageKeysHref = `${window.location.origin}/dashboard/#${ROUTE.USER_PREFERENCES}?tab=${UserPreferencesTab.AI_PROVIDER_KEYS}`; + + return ( + + + AI Provider Selector + + + + + + { + this.handleToggle('none'); + }} + id="accordion-item-no-ai" + > + Use a Default AI Provider + + + + + + + {this.buildDefaultProviderMessage()} + + + + + + + + + { + this.handleToggle('selector'); + }} + id="accordion-item-ai-selector" + > + Choose an AI Provider + + + + + + + this.handleProviderToggle(providerId)} + /> + + + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + aiProviders: selectAiProviders(state), + aiTools: selectAiTools(state), + defaultProviderIds: selectDefaultAiProviders(state), + providerKeyExists: selectAiProviderKeyExists(state), +}); + +const mapDispatchToProps = {}; + +const connector = connect(mapStateToProps, mapDispatchToProps, null, { + // forwardRef is mandatory for using `@react-mock/state` in unit tests + forwardRef: true, +}); + +type MappedProps = ConnectedProps; +export default connector(AiSelector); diff --git a/packages/dashboard-frontend/src/components/AiToolIcon/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiToolIcon/__tests__/index.spec.tsx new file mode 100644 index 0000000000..c964bf66b1 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiToolIcon/__tests__/index.spec.tsx @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2018-2025 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'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AiToolIcon } from '@/components/AiToolIcon'; +import { Workspace } from '@/services/workspace-adapter'; + +jest.mock('@/components/AiToolIcon/index.module.css', () => ({ + icon: 'icon', + container: 'container', + name: 'name', +})); + +const mockGetInjectedAiToolIds = jest.fn(); +jest.mock('@/services/helpers/aiTools', () => ({ + getInjectedAiToolIds: (...args: [Workspace, api.AiToolDefinition[]]) => + mockGetInjectedAiToolIds(...args), +})); + +const mockWorkspace: Workspace = { + ref: { + apiVersion: 'workspace.devfile.io/v1alpha2', + kind: 'DevWorkspace', + metadata: { + name: 'test-workspace', + namespace: 'test-ns', + uid: 'test-uid', + }, + spec: { + started: true, + template: { + components: [], + }, + }, + }, + id: 'test-id', + uid: 'test-uid', + name: 'test-workspace', + namespace: 'test-ns', + infrastructureNamespace: 'test-ns', + created: Date.now(), + updated: Date.now(), + status: 'RUNNING', + storageType: 'ephemeral', + projects: [], + isStarting: false, + isStopped: false, + isStopping: false, + isRunning: true, + hasError: false, + error: undefined, + isDevWorkspace: true, + isDeprecated: false, +} as Workspace; + +const mockAiTools: api.AiToolDefinition[] = [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai', + binary: 'claude', + pattern: 'bundle', + injectorImage: 'quay.io/test/claude-code:latest', + }, + { + providerId: 'github/copilot', + tag: 'latest', + name: 'GitHub Copilot', + url: 'https://copilot.github.com', + binary: 'copilot', + pattern: 'init', + injectorImage: 'quay.io/test/copilot:latest', + }, +]; + +const mockAiProviders: api.AiProviderDefinition[] = [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + icon: 'https://example.com/claude-icon.svg', + }, + { + id: 'github/copilot', + name: 'GitHub', + publisher: 'GitHub', + }, +]; + +describe('AiToolIcon', () => { + beforeEach(() => { + mockGetInjectedAiToolIds.mockReset(); + }); + + it('should render "-" when no tools are injected', () => { + mockGetInjectedAiToolIds.mockReturnValue([]); + + render( + , + ); + + expect(screen.getByText('-')).toBeTruthy(); + }); + + it('should render "-" when tool IDs do not match any tool definitions', () => { + mockGetInjectedAiToolIds.mockReturnValue(['unknown/tool']); + + render( + , + ); + + expect(screen.getByText('-')).toBeTruthy(); + }); + + it('should render single tool name and icon when one tool is injected', () => { + mockGetInjectedAiToolIds.mockReturnValue(['anthropic/claude']); + + render( + , + ); + + expect(screen.getAllByText('Claude Code').length).toBeGreaterThanOrEqual(1); + + const icon = screen.getByRole('img', { name: 'Claude Code' }); + expect(icon).toBeTruthy(); + expect(icon.getAttribute('src')).toBe('https://example.com/claude-icon.svg'); + expect(icon.classList.contains('icon')).toBe(true); + }); + + it('should render single tool name without icon when provider has no icon', () => { + mockGetInjectedAiToolIds.mockReturnValue(['github/copilot']); + + render( + , + ); + + expect(screen.getAllByText('GitHub Copilot').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('should render multiple tool icons for multiple tools', () => { + mockGetInjectedAiToolIds.mockReturnValue(['anthropic/claude', 'github/copilot']); + + render( + , + ); + + const icon = screen.getByRole('img', { name: 'Claude Code' }); + expect(icon).toBeTruthy(); + expect(icon.getAttribute('src')).toBe('https://example.com/claude-icon.svg'); + + // GitHub Copilot has no icon, so it renders as text + expect(screen.getByText('GitHub Copilot')).toBeTruthy(); + }); +}); diff --git a/packages/dashboard-frontend/src/components/AiToolIcon/index.module.css b/packages/dashboard-frontend/src/components/AiToolIcon/index.module.css new file mode 100644 index 0000000000..f8cb359b56 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiToolIcon/index.module.css @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +.container { + cursor: default; + white-space: nowrap; +} + +.icon { + width: 20px; + height: 20px; + margin-right: 5px; + padding: 1px; + + vertical-align: middle; + + border-radius: 3px; +} + +:global(html.pf-v6-theme-dark) .icon { + background-color: var(--pf-t--color--gray--10); +} + +.name { + white-space: nowrap; +} diff --git a/packages/dashboard-frontend/src/components/AiToolIcon/index.tsx b/packages/dashboard-frontend/src/components/AiToolIcon/index.tsx new file mode 100644 index 0000000000..13e8919149 --- /dev/null +++ b/packages/dashboard-frontend/src/components/AiToolIcon/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; +import { Tooltip } from '@patternfly/react-core'; +import React from 'react'; + +import styles from '@/components/AiToolIcon/index.module.css'; +import { getInjectedAiToolIds } from '@/services/helpers/aiTools'; +import { Workspace } from '@/services/workspace-adapter'; + +export type Props = { + workspace: Workspace; + aiTools: api.AiToolDefinition[]; + aiProviders: api.AiProviderDefinition[]; +}; + +export function AiToolIcon(props: Props): React.ReactElement { + const { workspace, aiTools, aiProviders } = props; + const toolIds = getInjectedAiToolIds(workspace, aiTools); + + if (toolIds.length === 0) { + return -; + } + + const tools = toolIds + .map(id => aiTools.find(t => t.providerId === id)) + .filter((t): t is api.AiToolDefinition => t !== undefined); + + if (tools.length === 0) { + return -; + } + + const tooltipContent = tools.map(t => t.name).join(', '); + + if (tools.length === 1) { + const tool = tools[0]; + const providerIcon = aiProviders.find(p => p.id === tool.providerId)?.icon; + const icon = providerIcon ? ( + {tool.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : null; + + return ( + + + {icon} + {tool.name} + + + ); + } + + // Multiple tools: show icons separated by comma, full names in tooltip + return ( + + + {tools.map((tool, index) => { + const providerIcon = aiProviders.find(p => p.id === tool.providerId)?.icon; + return ( + + {index > 0 && , } + {providerIcon ? ( + {tool.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + ) : ( + {tool.name} + )} + + ); + })} + + + ); +} diff --git a/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/index.spec.tsx index ab459b9c4c..1c8264d0bf 100644 --- a/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/BackupStatusBadge/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupStatus } from '@eclipse-che/common'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/components/BackupStatusBadge/index.module.css b/packages/dashboard-frontend/src/components/BackupStatusBadge/index.module.css index 472e719302..7a7a508547 100644 --- a/packages/dashboard-frontend/src/components/BackupStatusBadge/index.module.css +++ b/packages/dashboard-frontend/src/components/BackupStatusBadge/index.module.css @@ -32,7 +32,7 @@ } } -/* Generated by Claude Opus 4.6 */ +/* Generated by AI Assistant */ @media (prefers-reduced-motion: reduce) { .rotate { animation: none; diff --git a/packages/dashboard-frontend/src/components/BackupStatusBadge/index.tsx b/packages/dashboard-frontend/src/components/BackupStatusBadge/index.tsx index aba9d8679f..b67d8a5b2a 100644 --- a/packages/dashboard-frontend/src/components/BackupStatusBadge/index.tsx +++ b/packages/dashboard-frontend/src/components/BackupStatusBadge/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupStatus } from '@eclipse-che/common'; import { Icon, Label, LabelProps } from '@patternfly/react-core'; diff --git a/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx b/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx index b6589a7bc3..5d05c7728c 100644 --- a/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx +++ b/packages/dashboard-frontend/src/components/ImportFromGit/index.tsx @@ -39,6 +39,7 @@ import UntrustedSourceModal from '@/components/UntrustedSourceModal'; import { buildFactoryLoaderPath } from '@/preload/main'; import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; import { + AI_PROVIDER_ATTR, EDITOR_ATTR, EDITOR_IMAGE_ATTR, REVISION_ATTR, @@ -54,6 +55,7 @@ const FIELD_ID = 'git-repo-url'; export type Props = MappedProps & { editorDefinition: string | undefined; editorImage: string | undefined; + aiProviders?: string[]; navigate: NavigateFunction; }; export type State = { @@ -109,7 +111,7 @@ class ImportFromGit extends React.PureComponent { } private startFactory(): void { - const { editorDefinition, editorImage } = this.props; + const { editorDefinition, editorImage, aiProviders } = this.props; const factory = new FactoryLocationAdapter(this.state.location); // add the editor definition and editor image to the URL @@ -122,6 +124,13 @@ class ImportFromGit extends React.PureComponent { factory.searchParams.set(EDITOR_IMAGE_ATTR, editorImage); } } + if ( + aiProviders !== undefined && + aiProviders.length > 0 && + !factory.searchParams.has(AI_PROVIDER_ATTR) + ) { + factory.searchParams.set(AI_PROVIDER_ATTR, aiProviders.join(',')); + } if (this.state.gitBranch && !this.state.location.startsWith('http')) { factory.searchParams.set(REVISION_ATTR, this.state.gitBranch); } diff --git a/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css b/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css index 05f1a15d98..b1a89f3982 100644 --- a/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css +++ b/packages/dashboard-frontend/src/components/Workspace/Status/index.module.css @@ -4,7 +4,7 @@ } .statusIndicator { - margin-right: 10px; + margin-right: 5px; } svg.rotate { diff --git a/packages/dashboard-frontend/src/containers/RestoreFromBackup/index.tsx b/packages/dashboard-frontend/src/containers/RestoreFromBackup/index.tsx index 320d8f8820..7313dde6e0 100644 --- a/packages/dashboard-frontend/src/containers/RestoreFromBackup/index.tsx +++ b/packages/dashboard-frontend/src/containers/RestoreFromBackup/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; diff --git a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx index 226716012e..f44a8cbf8f 100644 --- a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx @@ -17,6 +17,7 @@ import { Location, NavigateFunction, useLocation, useNavigate } from 'react-rout import Fallback from '@/components/Fallback'; import WorkspacesList from '@/pages/WorkspacesList'; import { RootState } from '@/store'; +import { selectAiProviders, selectAiTools } from '@/store/AiConfig/selectors'; import { fetchBackupConfig, fetchWorkspaceBackupStatus } from '@/store/Backups/actions'; import { selectAllBackupsByWorkspace, selectBackupConfig } from '@/store/Backups/selectors'; import { selectBranding } from '@/store/Branding/selectors'; @@ -73,6 +74,8 @@ export class WorkspacesListContainer extends React.PureComponent { render() { const { + aiProviders, + aiTools, backupConfig, branding, allWorkspaces, @@ -95,6 +98,8 @@ export class WorkspacesListContainer extends React.PureComponent { location={location} navigate={navigate} workspaces={allWorkspaces} + aiProviders={aiProviders} + aiTools={aiTools} backupsByWorkspace={backupsByWorkspace} /> ); @@ -116,6 +121,8 @@ const mapStateToProps = (state: RootState) => { backupsByWorkspace: selectAllBackupsByWorkspace(state), editors: selectCmEditors(state), isLoading: selectIsLoading(state), + aiProviders: selectAiProviders(state), + aiTools: selectAiTools(state), }; }; diff --git a/packages/dashboard-frontend/src/index.tsx b/packages/dashboard-frontend/src/index.tsx index f3a646a243..682371a5c4 100644 --- a/packages/dashboard-frontend/src/index.tsx +++ b/packages/dashboard-frontend/src/index.tsx @@ -60,7 +60,9 @@ async function startApp(): Promise { } if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('./service-worker.js'); + navigator.serviceWorker.register('./service-worker.js').catch(e => { + console.warn('Service worker registration failed:', e); + }); } root.render( diff --git a/packages/dashboard-frontend/src/overrides.css b/packages/dashboard-frontend/src/overrides.css index 273be217ec..8e48191364 100644 --- a/packages/dashboard-frontend/src/overrides.css +++ b/packages/dashboard-frontend/src/overrides.css @@ -37,12 +37,9 @@ html.pf-v6-theme-dark .outer-fill { padding: 0 !important; } -html:not(.pf-v6-theme-dark) span[class*='label-required'] { - color: var(--pf-t--color--red-orange--60); -} - -html.pf-v6-theme-dark span[class*='label-required'] { - color: var(--pf-t--color--red-orange--30); +span[class*='label-required'] { + margin-left: 0; + color: var(--pf-t--global--color--status--danger--default); } /* Dark theme label contrast overrides */ @@ -123,3 +120,8 @@ html.pf-v6-theme-dark .pf-v6-c-page__main-section { tbody.pf-v6-c-table__tbody tr.pf-v6-c-table__tr td.pf-v6-c-table__td { vertical-align: middle; } + +.pf-v6-c-table tr:where(.pf-v6-c-table__tr) > :where(th, td):first-child { + top: 1px; + min-width: 10px; +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx index 16f606d562..ced82dc0ff 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx @@ -26,6 +26,7 @@ import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar'; import { ROUTE } from '@/Routes'; import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; import { + AI_PROVIDER_ATTR, DEV_WORKSPACE_ATTR, EDITOR_ATTR, EDITOR_IMAGE_ATTR, @@ -46,6 +47,7 @@ import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; export type Props = { editorDefinition: string | undefined; editorImage: string | undefined; + aiProviders?: string[]; presetFilter: string | undefined; } & MappedProps; @@ -96,7 +98,7 @@ class SamplesList extends React.PureComponent { } private async handleSampleCardClick(metadata: DevfileRegistryMetadata): Promise { - const { editorDefinition, editorImage } = this.props; + const { editorDefinition, editorImage, aiProviders } = this.props; // Handle SSH URLs (git@...) and HTTP(S) URLs differently let factoryUrl: string; @@ -127,6 +129,10 @@ class SamplesList extends React.PureComponent { factoryParams[EDITOR_IMAGE_ATTR] = editorImage; } + if (aiProviders !== undefined && aiProviders.length > 0) { + factoryParams[AI_PROVIDER_ATTR] = aiProviders.join(','); + } + const isEmptyWorkspace = metadata.tags?.some(tag => tag.toLowerCase() === 'empty') === true; const policiesCreate = isEmptyWorkspace ? 'perclick' : this.getPoliciesCreate(); if (policiesCreate !== 'peruser') { diff --git a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx index 15d93e7eae..e05a4e8d18 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx @@ -15,6 +15,8 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Location, NavigateFunction } from 'react-router-dom'; +import AiSelector from '@/components/AiSelector'; +import { AiSelectorErrorBoundary } from '@/components/AiSelector/ErrorBoundary'; import EditorSelector from '@/components/EditorSelector'; import Head from '@/components/Head'; import ImportFromGit from '@/components/ImportFromGit'; @@ -22,6 +24,7 @@ import { Spacer } from '@/components/Spacer'; import SamplesList from '@/pages/GetStarted/SamplesList'; import { ROUTE } from '@/Routes'; import { RootState } from '@/store'; +import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; import { selectDefaultEditor } from '@/store/ServerConfig/selectors'; type Props = MappedProps & { @@ -31,6 +34,7 @@ type Props = MappedProps & { type State = { editorDefinition: string | undefined; editorImage: string | undefined; + aiProviders: string[]; presetFilter: string | undefined; }; @@ -41,6 +45,7 @@ export class GetStarted extends React.PureComponent { this.state = { editorDefinition: undefined, editorImage: undefined, + aiProviders: [], presetFilter: this.getPresetFilter(), }; } @@ -77,8 +82,8 @@ export class GetStarted extends React.PureComponent { } render(): React.ReactNode { - const { defaultEditor, navigate } = this.props; - const { editorDefinition, editorImage, presetFilter } = this.state; + const { aiEnabled, defaultEditor, navigate } = this.props; + const { editorDefinition, editorImage, aiProviders, presetFilter } = this.state; const title = 'Create Workspace'; @@ -100,11 +105,22 @@ export class GetStarted extends React.PureComponent { } /> + {aiEnabled && ( + + + + + this.setState({ aiProviders: providerIds })} /> + + + )} + @@ -113,6 +129,7 @@ export class GetStarted extends React.PureComponent { @@ -122,6 +139,7 @@ export class GetStarted extends React.PureComponent { } const mapStateToProps = (state: RootState) => ({ + aiEnabled: selectAiConfigEnabled(state), defaultEditor: selectDefaultEditor(state), }); diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__mocks__/index.tsx index 4a72a04309..9eb503add0 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__tests__/index.spec.tsx index 84256bbb87..a4729f8aa6 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/index.tsx index 0973b6412d..22ebf79069 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ConfirmationModal/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { Alert, diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__mocks__/index.tsx index d374455f2f..8a90de2d00 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__tests__/index.spec.tsx index bf7ae22e9d..8922d5ee34 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/index.tsx index d681df8d3a..ba79305f79 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import { diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__mocks__/index.tsx index 3ed072ae02..85429a0625 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__tests__/index.spec.tsx index 4e3b6dddef..6d1195a14f 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/index.tsx index f21d85a8f7..56222bd711 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { FormGroup, HelperText, HelperTextItem } from '@patternfly/react-core'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__mocks__/index.tsx index efd11684ad..d500061c0d 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__tests__/index.spec.tsx index 38c916b6ea..c7c4a7bc57 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx index b674e9140a..2ed9458531 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Sonnet 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import { Form } from '@patternfly/react-core'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/__mocks__/index.tsx index beb57f7593..fd47358d15 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/index.tsx index ea997f3755..3171870a31 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { FormGroup, diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__mocks__/index.tsx index 5c823a2be2..8da50038ac 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__mocks__/index.tsx index 0f4cb357a9..dcc067f3de 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__tests__/index.spec.tsx index cdebb88939..a59cdb1ed0 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/index.tsx index d1a287c0f9..75c74b92f2 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ResourceLimitsPanel/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { Accordion, diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__mocks__/index.tsx index 30c8a520ec..9074c32ab1 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__mocks__/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__tests__/index.spec.tsx index 71fb4bfd0f..42429cf7c3 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/index.tsx index 513786eb80..8280fd893a 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/RestoreModeAccordion/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import { diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx index f8b8b57675..79843902e6 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Sonnet 4.6 +// Generated by AI Assistant import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx index 2c9c182c44..bca4e02219 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Sonnet 4.6 +// Generated by AI Assistant import { FormGroup, diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/index.spec.tsx index 55f029bf8c..5218e32df5 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; @@ -180,7 +180,7 @@ describe('RestoreFromBackupPage', () => { expect(screen.getByText('Select a backup to restore')).toBeInTheDocument(); }); - // Generated by Claude Opus 4.6 + // Generated by AI Assistant test('should show error and disable restore when active backup is selected', async () => { const existingWorkspaceNames = new Set(['my-workspace']); renderComponent({ backups: mockBackups, existingWorkspaceNames }); @@ -290,7 +290,7 @@ describe('RestoreFromBackupPage', () => { }); }); - // Generated by Claude Opus 4.6 + // Generated by AI Assistant test('should hide error when user provides a different workspace name', async () => { const existingWorkspaceNames = new Set(['my-workspace']); renderComponent({ backups: mockBackups, existingWorkspaceNames }); diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/index.tsx index cc12664b27..2e39e3ebab 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { Alert, diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.module.css b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.module.css new file mode 100644 index 0000000000..76c8453c81 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.module.css @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +.questionIcon { + cursor: pointer; + color: var(--pf-t--global--icon--color--subtle); + vertical-align: -0.2em; +} + +.questionIcon:hover { + color: currentcolor; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.tsx new file mode 100644 index 0000000000..fc3350ea8e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.tsx @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2018-2025 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'; +import { + FormGroup, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + TextInput, + ValidatedOptions, +} from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import { CheTooltip } from '@/components/CheTooltip'; +import styles from '@/pages/UserPreferences/AiProviderKeys/AddEditModal/Form/index.module.css'; + +export type Props = { + providers: api.AiToolDefinition[]; + /** Pre-selected provider (update mode). When set the provider selector is hidden. */ + fixedProvider?: api.AiToolDefinition; + onChange: (providerId: string, apiKey: string, isValid: boolean) => void; +}; + +export type State = { + selectedProviderId: string; + apiKey: string; + isProviderSelectOpen: boolean; +}; + +export class AiProviderKeysAddEditForm extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + selectedProviderId: props.fixedProvider?.providerId ?? (props.providers[0]?.providerId || ''), + apiKey: '', + isProviderSelectOpen: false, + }; + } + + private get isValid(): boolean { + return this.state.selectedProviderId.length > 0 && this.state.apiKey.trim().length > 0; + } + + private handleProviderSelect( + _event: React.MouseEvent | undefined, + providerId: string | number, + ): void { + this.setState({ selectedProviderId: String(providerId), isProviderSelectOpen: false }, () => { + this.props.onChange(this.state.selectedProviderId, this.state.apiKey, this.isValid); + }); + } + + private handleApiKeyChange(value: string): void { + this.setState({ apiKey: value }, () => { + this.props.onChange(this.state.selectedProviderId, this.state.apiKey, this.isValid); + }); + } + + public render(): React.ReactElement { + const { providers, fixedProvider } = this.props; + const { selectedProviderId, apiKey, isProviderSelectOpen } = this.state; + + const selectedProvider = + fixedProvider ?? providers.find(p => p.providerId === selectedProviderId) ?? providers[0]; + + const apiKeyValidated = + apiKey.length === 0 ? ValidatedOptions.default : ValidatedOptions.success; + + return ( + + {!fixedProvider && ( + + + + )} + + + API Key  + {selectedProvider?.url && ( + + Get your key at  + + {selectedProvider.url} + + + } + > + + + )} + + } + isRequired + fieldId="ai-provider-api-key" + > + this.handleApiKeyChange(val)} + aria-label="API key input" + /> + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..69538fb371 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/__tests__/index.spec.tsx @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2018-2025 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'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AiProviderKeysAddEditModal } from '@/pages/UserPreferences/AiProviderKeys/AddEditModal'; + +const mockProvider: api.AiToolDefinition = { + providerId: 'google/gemini', + tag: 'next', + name: 'Gemini CLI', + url: 'https://ai.google.dev', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', +}; + +describe('AiProviderKeysAddEditModal', () => { + it('should show "Add AI Provider Key" title in add mode', () => { + render( + , + ); + + expect(screen.getByText('Add AI Provider Key')).toBeDefined(); + }); + + it('should show update title when fixedProvider is set', () => { + render( + , + ); + + expect(screen.getByText('Update Gemini CLI API Key')).toBeDefined(); + }); + + it('should disable save button initially', () => { + render( + , + ); + + expect(screen.getByTestId('save-button')).toBeDisabled(); + }); + + it('should call onCloseModal when cancel is clicked', async () => { + const onCloseModal = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('cancel-button')); + expect(onCloseModal).toHaveBeenCalledTimes(1); + }); + + it('should enable save button after entering API key', async () => { + render( + , + ); + + const input = screen.getByLabelText('API key input'); + await userEvent.type(input, 'test-api-key'); + + expect(screen.getByTestId('save-button')).not.toBeDisabled(); + }); + + it('should call onSave with providerId and apiKey', async () => { + const onSave = jest.fn(); + render( + , + ); + + const input = screen.getByLabelText('API key input'); + await userEvent.type(input, 'my-secret-key'); + await userEvent.click(screen.getByTestId('save-button')); + + expect(onSave).toHaveBeenCalledWith('google/gemini', 'my-secret-key'); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/index.tsx new file mode 100644 index 0000000000..8635bd3a6a --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/AddEditModal/index.tsx @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2018-2025 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'; +import { + Button, + ButtonVariant, + Form, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core'; +import React from 'react'; + +import { AiProviderKeysAddEditForm } from '@/pages/UserPreferences/AiProviderKeys/AddEditModal/Form'; + +export type Props = { + isOpen: boolean; + availableProviders: api.AiToolDefinition[]; + fixedProvider?: api.AiToolDefinition; + onSave: (providerId: string, apiKey: string) => void; + onCloseModal: () => void; +}; + +export type State = { + providerId: string; + apiKey: string; + isSaveEnabled: boolean; +}; + +export class AiProviderKeysAddEditModal extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + providerId: + props.fixedProvider?.providerId ?? (props.availableProviders[0]?.providerId || ''), + apiKey: '', + isSaveEnabled: false, + }; + } + + public componentDidUpdate(prevProps: Props): void { + if (prevProps.isOpen !== this.props.isOpen && this.props.isOpen) { + this.setState({ + providerId: + this.props.fixedProvider?.providerId ?? + (this.props.availableProviders[0]?.providerId || ''), + apiKey: '', + isSaveEnabled: false, + }); + } + } + + private handleSave(): void { + const { providerId, apiKey } = this.state; + if (providerId && apiKey) { + this.props.onSave(providerId, apiKey); + } + } + + private handleFormChange(providerId: string, apiKey: string, isValid: boolean): void { + this.setState({ providerId, apiKey, isSaveEnabled: isValid }); + } + + public render(): React.ReactElement { + const { isOpen, availableProviders, fixedProvider, onCloseModal } = this.props; + const { isSaveEnabled } = this.state; + + const isUpdateMode = fixedProvider !== undefined; + const modalTitle = isUpdateMode + ? `Update ${fixedProvider.name} API Key` + : 'Add AI Provider Key'; + + return ( + + + +
+
e.preventDefault()}> + this.handleFormChange(...args)} + /> + +
+
+ + + + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e2425849d4 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/__tests__/index.spec.tsx @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2018-2025 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'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AiProviderKeysDeleteModal } from '@/pages/UserPreferences/AiProviderKeys/DeleteModal'; + +const mockProvider: api.AiToolDefinition = { + providerId: 'google/gemini', + tag: 'next', + name: 'Gemini CLI', + url: 'https://ai.google.dev', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', +}; + +const mockProvider2: api.AiToolDefinition = { + providerId: 'anthropic/claude', + tag: 'next', + name: 'Claude Code', + url: 'https://claude.ai/code', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude-code:next', + envVarName: 'ANTHROPIC_API_KEY', +}; + +describe('AiProviderKeysDeleteModal', () => { + it('should show single provider delete title', () => { + render( + , + ); + + expect(screen.getByText('Delete Gemini CLI API Key')).toBeDefined(); + }); + + it('should show multi-provider delete title', () => { + render( + , + ); + + expect(screen.getByText('Delete 2 API Keys')).toBeDefined(); + }); + + it('should disable delete button until checkbox is checked', async () => { + render( + , + ); + + const deleteBtn = screen.getByRole('button', { name: 'Delete' }); + expect(deleteBtn).toBeDisabled(); + + await userEvent.click(screen.getByRole('checkbox')); + expect(deleteBtn).not.toBeDisabled(); + }); + + it('should call onDelete when delete is clicked', async () => { + const onDelete = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('checkbox')); + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(onDelete).toHaveBeenCalledWith([mockProvider]); + }); + + it('should call onCloseModal when cancel is clicked', async () => { + const onCloseModal = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCloseModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/index.tsx new file mode 100644 index 0000000000..30a570d3d9 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/DeleteModal/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2018-2025 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'; +import { + Button, + ButtonVariant, + Checkbox, + Content, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core'; +import React from 'react'; + +export type Props = { + isOpen: boolean; + providers: api.AiToolDefinition[]; + onCloseModal: () => void; + onDelete: (providers: api.AiToolDefinition[]) => void; +}; + +export type State = { + isChecked: boolean; +}; + +export class AiProviderKeysDeleteModal extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { isChecked: false }; + } + + private handleDelete(): void { + const { providers } = this.props; + if (providers.length > 0) { + this.setState({ isChecked: false }); + this.props.onDelete(providers); + } + } + + private handleCloseModal(): void { + this.setState({ isChecked: false }); + this.props.onCloseModal(); + } + + public render(): React.ReactElement { + const { isOpen, providers } = this.props; + const { isChecked } = this.state; + + const count = providers.length; + const modalTitle = + count === 1 ? `Delete ${providers[0].name} API Key` : `Delete ${count} API Keys`; + + const bodyText = + count === 1 ? ( + + Are you sure you want to delete the {providers[0].name} API key? The key + will be removed from all your workspaces immediately. + + ) : ( + + Are you sure you want to delete {count} API keys ( + {providers.map(p => p.name).join(', ')})? The keys will be removed from all your + workspaces immediately. + + ); + + return ( + this.handleCloseModal()} + elementToFocus="[data-pf-initial-focus]" + > + + + + {bodyText} + this.setState({ isChecked: checked })} + /> + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/__tests__/index.spec.tsx new file mode 100644 index 0000000000..cb86727503 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/__tests__/index.spec.tsx @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AiProviderKeysEmptyState } from '@/pages/UserPreferences/AiProviderKeys/EmptyState'; + +describe('AiProviderKeysEmptyState', () => { + it('should show admin message when no providers are configured', () => { + render( + , + ); + + expect(screen.getByText('No AI Providers Configured')).toBeDefined(); + expect(screen.getByText(/Ask your administrator/)).toBeDefined(); + }); + + it('should show add key button when providers exist', () => { + render( + , + ); + + expect(screen.getByText('No AI Providers Keys')).toBeDefined(); + expect(screen.getByRole('button', { name: /Add AI Provider Key/i })).toBeDefined(); + }); + + it('should call onAddKey when button is clicked', async () => { + const onAddKey = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Add AI Provider Key/i })); + expect(onAddKey).toHaveBeenCalledTimes(1); + }); + + it('should disable add button when isDisabled is true', () => { + render(); + + expect(screen.getByRole('button', { name: /Add AI Provider Key/i })).toBeDisabled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/index.tsx new file mode 100644 index 0000000000..fd7cef6f7e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/EmptyState/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2018-2025 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 { + Button, + EmptyState, + EmptyStateBody, + EmptyStateVariant, + PageSection, +} from '@patternfly/react-core'; +import { KeyIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; + +export type Props = { + isDisabled: boolean; + hasProviders: boolean; + onAddKey: () => void; +}; + +export class AiProviderKeysEmptyState extends React.PureComponent { + public render(): React.ReactElement { + const { isDisabled, hasProviders, onAddKey } = this.props; + + if (!hasProviders) { + return ( + + + + Ask your administrator to configure AI providers in the CheCluster custom resource. + + + + ); + } + + return ( + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.module.css b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.module.css new file mode 100644 index 0000000000..6ec506cd9a --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.module.css @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +.providerIcon { + height: 100%; + margin: 0 6px 3px 0; + vertical-align: middle; + border-radius: 2px; +} + +:global(html.pf-v6-theme-dark) .providerIcon { + background-color: var(--pf-t--color--gray--10); +} + +.noKeyLabel { + color: var(--pf-t--global--color--nonstatus--gray--100); +} + +.actionsCell { + width: 80px !important; + max-width: 80px !important; +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.tsx new file mode 100644 index 0000000000..ad04b6ce79 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/List/index.tsx @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2018-2025 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'; +import { + Button, + ButtonVariant, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { ActionsColumn, IAction, Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import React from 'react'; + +import styles from '@/pages/UserPreferences/AiProviderKeys/List/index.module.css'; + +export type Props = { + isDisabled: boolean; + providers: api.AiToolDefinition[]; + aiProviders: api.AiProviderDefinition[]; + providerKeyExists: Record; + canAddMore: boolean; + onAddKey: () => void; + onUpdateKey: (provider: api.AiToolDefinition) => void; + onDeleteKey: (providers: api.AiToolDefinition[]) => void; +}; + +type State = { + selectedItems: api.AiToolDefinition[]; +}; + +export class AiProviderKeysList extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { selectedItems: [] }; + } + + private handleSelectItem(isSelected: boolean, rowIndex: number): void { + const { providers } = this.props; + + if (rowIndex === -1) { + const selectedItems = isSelected && providers.length > 0 ? [...providers] : []; + this.setState({ selectedItems }); + return; + } + + const item = providers[rowIndex]; + this.setState((prev: State) => ({ + selectedItems: isSelected + ? [...prev.selectedItems, item] + : prev.selectedItems.filter(s => s.providerId !== item.providerId), + })); + } + + private deselectItems(items: api.AiToolDefinition[]): void { + this.setState(prev => ({ + selectedItems: prev.selectedItems.filter( + s => !items.some(i => i.providerId === s.providerId), + ), + })); + } + + private handleDeleteSelected(): void { + const { selectedItems } = this.state; + this.props.onDeleteKey(selectedItems); + this.deselectItems(selectedItems); + } + + private buildActionItems(provider: api.AiToolDefinition): IAction[] { + const { isDisabled, providerKeyExists } = this.props; + const hasKey = providerKeyExists[provider.providerId]; + const requiresKey = !!provider.envVarName; + const actions: IAction[] = []; + + if (requiresKey) { + actions.push({ + title: 'Update', + isDisabled, + onClick: () => this.props.onUpdateKey(provider), + }); + } + if (hasKey) { + actions.push({ + title: 'Delete', + isDisabled, + onClick: () => { + this.props.onDeleteKey([provider]); + this.deselectItems([provider]); + }, + }); + } + return actions; + } + + private getProviderIcon(tool: api.AiToolDefinition): string | undefined { + return this.props.aiProviders.find(p => p.id === tool.providerId)?.icon; + } + + public render(): React.ReactElement { + const { isDisabled, providers, canAddMore } = this.props; + const { selectedItems } = this.state; + + const deleteDisabled = isDisabled || selectedItems.length === 0; + + return ( + + + + + + + + + + + + + + + + + + + + + {providers.map((provider, rowIndex) => { + const hasKey = this.props.providerKeyExists[provider.providerId]; + const requiresKey = !!provider.envVarName; + const rowDisabled = isDisabled || !hasKey; + const actionItems = this.buildActionItems(provider); + + return ( + + + + + + ); + })} + +
this.handleSelectItem(isSelected, -1), + isSelected: providers.length > 0 && selectedItems.length === providers.length, + }} + /> + AI ProviderEnvironment Variable +
this.handleSelectItem(isSelected, rowIndex), + isSelected: selectedItems.includes(provider), + isDisabled: rowDisabled, + }} + /> + + + {this.getProviderIcon(provider) && ( + {`${provider.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} + {provider.name} + + + {requiresKey ? ( + {provider.envVarName} + ) : ( + No API key required + )} + + +
+
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/List.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/List.spec.tsx new file mode 100644 index 0000000000..261336b118 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/List.spec.tsx @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { AiProviderKeysList, Props } from '@/pages/UserPreferences/AiProviderKeys/List'; + +jest.mock('@/pages/UserPreferences/AiProviderKeys/List/index.module.css', () => ({ + providerIcon: 'providerIcon', + envVarColumn: 'envVarColumn', + envVarCell: 'envVarCell', + noKeyLabel: 'noKeyLabel', + actionsCell: 'actionsCell', +})); + +const mockTool1: api.AiToolDefinition = { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://example.com/claude', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', +}; + +const mockTool2: api.AiToolDefinition = { + providerId: 'openai/gpt', + tag: 'latest', + name: 'GPT', + url: 'https://example.com/gpt', + binary: 'gpt', + pattern: 'init', + injectorImage: 'quay.io/example/gpt:latest', + envVarName: 'OPENAI_API_KEY', +}; + +const mockAiProviders: api.AiProviderDefinition[] = [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + icon: 'https://example.com/anthropic.svg', + }, + { + id: 'openai/gpt', + name: 'OpenAI', + publisher: 'OpenAI', + }, +]; + +describe('AiProviderKeysList', () => { + const mockOnAddKey = jest.fn(); + const mockOnUpdateKey = jest.fn(); + const mockOnDeleteKey = jest.fn(); + + function getDefaultProps(overrides: Partial = {}): Props { + return { + isDisabled: false, + providers: [mockTool1, mockTool2], + aiProviders: mockAiProviders, + providerKeyExists: { + 'anthropic/claude': true, + 'openai/gpt': true, + }, + canAddMore: true, + onAddKey: mockOnAddKey, + onUpdateKey: mockOnUpdateKey, + onDeleteKey: mockOnDeleteKey, + ...overrides, + }; + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the table with provider rows', () => { + render(); + + expect(screen.getByText('Claude Code')).toBeTruthy(); + expect(screen.getByText('GPT')).toBeTruthy(); + }); + + it('should show "Add AI Provider Key" button', () => { + render(); + + const addButton = screen.getByTestId('add-ai-provider-key-button'); + expect(addButton).toBeTruthy(); + expect(addButton).not.toBeDisabled(); + }); + + it('should disable "Add AI Provider Key" when canAddMore is false', () => { + render(); + + const addButton = screen.getByTestId('add-ai-provider-key-button'); + expect(addButton).toBeDisabled(); + }); + + it('should show environment variable name in code tag', () => { + render(); + + const codeElements = screen.getAllByText(/API_KEY/); + expect(codeElements.length).toBeGreaterThanOrEqual(2); + + const anthropicCode = screen.getByText('ANTHROPIC_API_KEY'); + expect(anthropicCode.tagName).toBe('CODE'); + + const openaiCode = screen.getByText('OPENAI_API_KEY'); + expect(openaiCode.tagName).toBe('CODE'); + }); + + it('should call onAddKey when add button is clicked', () => { + render(); + + const addButton = screen.getByTestId('add-ai-provider-key-button'); + fireEvent.click(addButton); + + expect(mockOnAddKey).toHaveBeenCalledTimes(1); + }); + + it('should have Delete button disabled when nothing is selected', () => { + render(); + + const deleteButton = screen.getByTestId('bulk-delete-ai-key-button'); + expect(deleteButton).toBeDisabled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ca03a05519 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/__tests__/index.spec.tsx @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import AiProviderKeys from '@/pages/UserPreferences/AiProviderKeys'; +import { AppThunk } from '@/store'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { aiConfigActionCreators, AiConfigState } from '@/store/AiConfig'; + +jest.mock('@/pages/UserPreferences/AiProviderKeys/AddEditModal', () => ({ + AiProviderKeysAddEditModal: () =>
, +})); +jest.mock('@/pages/UserPreferences/AiProviderKeys/DeleteModal', () => ({ + AiProviderKeysDeleteModal: () =>
, +})); +jest.mock('@/pages/UserPreferences/AiProviderKeys/EmptyState', () => ({ + AiProviderKeysEmptyState: (props: { hasProviders: boolean }) => ( +
{props.hasProviders ? 'has-providers' : 'no-providers'}
+ ), +})); +jest.mock('@/pages/UserPreferences/AiProviderKeys/List', () => ({ + AiProviderKeysList: () =>
, +})); + +const mockRequestAiProviderKeyStatus = jest.fn(); +const mockSaveAiProviderKey = jest.fn(); +const mockDeleteAiProviderKey = jest.fn(); +jest.mock('@/store/AiConfig', () => ({ + ...jest.requireActual('@/store/AiConfig'), + aiConfigActionCreators: { + requestAiProviderKeyStatus: + (...args: unknown[]): AppThunk => + async () => + mockRequestAiProviderKeyStatus(...args), + saveAiProviderKey: + (...args: unknown[]): AppThunk => + async () => + mockSaveAiProviderKey(...args), + deleteAiProviderKey: + (...args: unknown[]): AppThunk => + async () => + mockDeleteAiProviderKey(...args), + } as typeof aiConfigActionCreators, +})); + +jest.mock('@/inversify.config', () => ({ + lazyInject: () => () => undefined, +})); + +// mute console.error +console.error = jest.fn(); + +function buildStoreWithAiConfig(aiConfig: Partial) { + const fullAiConfig: AiConfigState = { + providers: [], + tools: [], + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, + ...aiConfig, + }; + + return new MockStoreBuilder({ aiConfig: fullAiConfig } as Record).build(); +} + +function renderComponent(aiConfig: Partial = {}): void { + const store = buildStoreWithAiConfig(aiConfig); + render( + + + , + ); +} + +describe('AiProviderKeys', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should show empty state when no keys exist', () => { + renderComponent({ + providers: [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + }, + ], + tools: [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://example.com/claude', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', + }, + ], + providerKeyExists: { + 'anthropic/claude': false, + }, + }); + + expect(screen.getByTestId('empty-state')).toBeTruthy(); + expect(screen.queryByTestId('keys-list')).toBeNull(); + }); + + it('should show keys list when keys exist', () => { + renderComponent({ + providers: [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + }, + ], + tools: [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://example.com/claude', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', + }, + ], + providerKeyExists: { + 'anthropic/claude': true, + }, + }); + + expect(screen.getByTestId('keys-list')).toBeTruthy(); + expect(screen.queryByTestId('empty-state')).toBeNull(); + }); + + it('should show progress indicator when loading', () => { + renderComponent({ + isLoading: true, + tools: [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://example.com/claude', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', + }, + ], + providerKeyExists: {}, + }); + + expect(screen.getByRole('progressbar', { name: 'Action is in progress' })).toBeTruthy(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/index.tsx new file mode 100644 index 0000000000..af19cba01e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/UserPreferences/AiProviderKeys/index.tsx @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2018-2025 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, helpers } from '@eclipse-che/common'; +import { AlertVariant, PageSection } from '@patternfly/react-core'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import ProgressIndicator from '@/components/Progress'; +import { lazyInject } from '@/inversify.config'; +import { AiProviderKeysAddEditModal } from '@/pages/UserPreferences/AiProviderKeys/AddEditModal'; +import { AiProviderKeysDeleteModal } from '@/pages/UserPreferences/AiProviderKeys/DeleteModal'; +import { AiProviderKeysEmptyState } from '@/pages/UserPreferences/AiProviderKeys/EmptyState'; +import { AiProviderKeysList } from '@/pages/UserPreferences/AiProviderKeys/List'; +import { AppAlerts } from '@/services/alerts/appAlerts'; +import { RootState } from '@/store'; +import { + aiConfigActionCreators, + selectAiConfigError, + selectAiConfigIsLoading, + selectAiProviderKeyExists, + selectAiProviders, + selectAiTools, +} from '@/store/AiConfig'; + +export type Props = MappedProps; + +export type State = { + isAddEditOpen: boolean; + isDeleteOpen: boolean; + editingProvider: api.AiToolDefinition | undefined; + deletingProviders: api.AiToolDefinition[]; +}; + +class AiProviderKeys extends React.PureComponent { + @lazyInject(AppAlerts) + private readonly appAlerts: AppAlerts; + + constructor(props: Props) { + super(props); + this.state = { + isAddEditOpen: false, + isDeleteOpen: false, + editingProvider: undefined, + deletingProviders: [], + }; + } + + public async componentDidMount(): Promise { + if (this.props.isLoading) { + return; + } + try { + await this.props.requestAiProviderKeyStatus(); + } catch (e) { + this.appAlerts.showAlert({ + key: 'request-ai-config-failed', + variant: AlertVariant.danger, + title: helpers.errors.getMessage(e), + }); + } + } + + public componentDidUpdate(prevProps: Props): void { + const { error } = this.props; + if (error && error !== prevProps.error) { + this.appAlerts.showAlert({ + key: 'ai-provider-key-error', + title: helpers.errors.getMessage(error), + variant: AlertVariant.danger, + }); + } + } + + private handleShowAddModal(): void { + this.setState({ isAddEditOpen: true, editingProvider: undefined }); + } + + private handleShowUpdateModal(provider: api.AiToolDefinition): void { + this.setState({ isAddEditOpen: true, editingProvider: provider }); + } + + private handleCloseAddEditModal(): void { + this.setState({ isAddEditOpen: false, editingProvider: undefined }); + } + + private handleShowDeleteModal(providers: api.AiToolDefinition[]): void { + this.setState({ isDeleteOpen: true, deletingProviders: providers }); + } + + private handleCloseDeleteModal(): void { + this.setState({ isDeleteOpen: false, deletingProviders: [] }); + } + + private async handleSave(toolId: string, apiKey: string): Promise { + const tool = this.props.tools.find(t => t.providerId === toolId); + const name = tool?.name ?? toolId; + + try { + await this.props.saveAiProviderKey(toolId, apiKey); + this.appAlerts.showAlert({ + key: 'ai-provider-key-saved', + title: `${name} API key saved successfully.`, + variant: AlertVariant.success, + }); + } catch (e) { + this.appAlerts.showAlert({ + key: 'ai-provider-key-save-failed', + title: helpers.errors.getMessage(e), + variant: AlertVariant.danger, + }); + } finally { + this.setState({ isAddEditOpen: false, editingProvider: undefined }); + } + } + + private async handleDelete(providers: api.AiToolDefinition[]): Promise { + try { + for (const provider of providers) { + await this.props.deleteAiProviderKey(provider.providerId); + } + const names = providers.map(p => p.name).join(', '); + this.appAlerts.showAlert({ + key: 'ai-provider-key-deleted', + title: + providers.length === 1 + ? `${names} API key deleted successfully.` + : `${providers.length} API keys deleted successfully: ${names}.`, + variant: AlertVariant.success, + }); + } catch (e) { + this.appAlerts.showAlert({ + key: 'ai-provider-key-delete-failed', + title: helpers.errors.getMessage(e), + variant: AlertVariant.danger, + }); + } finally { + this.setState({ isDeleteOpen: false, deletingProviders: [] }); + } + } + + public render(): React.ReactElement { + const { tools, providerKeyExists, isLoading } = this.props; + const { isAddEditOpen, isDeleteOpen, editingProvider, deletingProviders } = this.state; + + // Only show tools that require an API key (have envVarName) + const keyTools = tools.filter(t => !!t.envVarName); + const canAddMore = keyTools.some(t => !providerKeyExists[t.providerId]); + const availableTools = keyTools.filter(t => !providerKeyExists[t.providerId]); + + // List: only tools that require a key (tools without envVarName have nothing to manage here) + const listTools = keyTools.filter(t => providerKeyExists[t.providerId]); + + return ( + + + + this.handleSave(...args)} + onCloseModal={() => this.handleCloseAddEditModal()} + /> + + this.handleCloseDeleteModal()} + onDelete={providers => this.handleDelete(providers)} + /> + + {listTools.length === 0 ? ( + 0} + onAddKey={() => this.handleShowAddModal()} + /> + ) : ( + this.handleShowAddModal()} + onUpdateKey={provider => this.handleShowUpdateModal(provider)} + onDeleteKey={providers => this.handleShowDeleteModal(providers)} + /> + )} + + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + aiProviders: selectAiProviders(state), + tools: selectAiTools(state), + providerKeyExists: selectAiProviderKeyExists(state), + isLoading: selectAiConfigIsLoading(state), + error: selectAiConfigError(state), +}); + +const connector = connect( + mapStateToProps, + { + requestAiProviderKeyStatus: aiConfigActionCreators.requestAiProviderKeyStatus, + saveAiProviderKey: aiConfigActionCreators.saveAiProviderKey, + deleteAiProviderKey: aiConfigActionCreators.deleteAiProviderKey, + }, + null, + { forwardRef: true }, +); + +type MappedProps = ConnectedProps; +export default connector(AiProviderKeys); diff --git a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx index e4ab582e58..143074b5b3 100644 --- a/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx +++ b/packages/dashboard-frontend/src/pages/UserPreferences/index.tsx @@ -16,6 +16,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { Location, NavigateFunction } from 'react-router-dom'; import Head from '@/components/Head'; +import AiProviderKeys from '@/pages/UserPreferences/AiProviderKeys'; import ContainerRegistries from '@/pages/UserPreferences/ContainerRegistriesTab'; import GitConfig from '@/pages/UserPreferences/GitConfig'; import GitServices from '@/pages/UserPreferences/GitServices'; @@ -24,6 +25,7 @@ import SshKeys from '@/pages/UserPreferences/SshKeys'; import { ROUTE } from '@/Routes'; import { UserPreferencesTab } from '@/services/helpers/types'; import { RootState } from '@/store'; +import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; import { gitOauthConfigActionCreators } from '@/store/GitOauthConfig'; import { selectIsLoading } from '@/store/GitOauthConfig/selectors'; @@ -56,14 +58,16 @@ class UserPreferences extends React.PureComponent { } private getActiveTabKey(): UserPreferencesTab { - const { pathname, search } = this.props.location; + const { aiEnabled, location } = this.props; + const { pathname, search } = location; if (search) { const searchParam = new URLSearchParams(search); const tab = searchParam.get('tab'); if ( pathname === ROUTE.USER_PREFERENCES && - (tab === UserPreferencesTab.CONTAINER_REGISTRIES || + ((tab === UserPreferencesTab.AI_PROVIDER_KEYS && aiEnabled) || + tab === UserPreferencesTab.CONTAINER_REGISTRIES || tab === UserPreferencesTab.GITCONFIG || tab === UserPreferencesTab.GIT_SERVICES || tab === UserPreferencesTab.PERSONAL_ACCESS_TOKENS || @@ -174,6 +178,11 @@ class UserPreferences extends React.PureComponent { > + {this.props.aiEnabled && ( + + + + )} @@ -182,6 +191,7 @@ class UserPreferences extends React.PureComponent { } const mapStateToProps = (state: RootState) => ({ + aiEnabled: selectAiConfigEnabled(state), isLoading: selectIsLoading(state), }); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/Info/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/Info/index.tsx index fba8a09dd1..61614182ab 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/Info/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/Info/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupInfo, BackupStatus } from '@eclipse-che/common'; import { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/index.tsx index c22277ae30..4b2a49ba1e 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/BackupTab/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupInfo } from '@eclipse-che/common'; import { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx new file mode 100644 index 0000000000..2f84d389b6 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2018-2025 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'; +import { Content, Modal, ModalBody, ModalHeader, ModalVariant } from '@patternfly/react-core'; +import React from 'react'; + +type Props = { + isOpen: boolean; + aiTools: api.AiToolDefinition[]; + aiProviders: api.AiProviderDefinition[]; + onClose: () => void; +}; + +export class AiToolInfoModal extends React.PureComponent { + public render(): React.ReactNode { + const { isOpen, aiTools, aiProviders, onClose } = this.props; + + return ( + + + +
+ {aiTools.length === 0 ? ( + + + No AI tools are available. Ask your administrator to configure AI tools in the + CheCluster custom resource. + + + ) : ( + + + AI coding tools are injected into workspace containers at start via init + containers. The selected tool binary is copied to a shared volume and added to{' '} + PATH. + + {aiTools.map(def => { + const provider = aiProviders.find(p => p.id === def.providerId); + return ( + + + + {def.name} + + + {provider?.description && <> — {provider.description}} + + ); + })} + + )} +
+
+
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx new file mode 100644 index 0000000000..27260312c7 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2018-2025 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'; +import { + Button, + Checkbox, + Content, + ContentVariants, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, +} from '@patternfly/react-core'; +import React from 'react'; + +type Props = { + isOpen: boolean; + aiTools: api.AiToolDefinition[]; + aiProviders: api.AiProviderDefinition[]; + selected: string[]; + originSelection: string[]; + onToggle: (toolId: string) => void; + onConfirm: () => void; + onCancel: () => void; +}; + +export class AiToolSelectorModal extends React.PureComponent { + public render(): React.ReactNode { + const { + isOpen, + aiTools, + aiProviders, + selected, + originSelection, + onToggle, + onConfirm, + onCancel, + } = this.props; + + const hasChanged = + selected.length !== originSelection.length || + selected.some(id => !originSelection.includes(id)); + + return ( + + + + + {aiTools.length === 0 ? ( + + No AI tools are available. Ask your administrator to configure AI tools in the + CheCluster custom resource. + + ) : ( + <> + Select AI coding tools + {aiTools.map(def => { + const provider = aiProviders.find(p => p.id === def.providerId); + return ( + + onToggle(def.providerId)} + /> + + ); + })} + + )} + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/InfoModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/InfoModal.spec.tsx new file mode 100644 index 0000000000..3abb1ed0af --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/InfoModal.spec.tsx @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2018-2025 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'; +import React from 'react'; + +import { AiToolInfoModal } from '@/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal'; +import getComponentRenderer, { fireEvent, screen } from '@/services/__mocks__/getComponentRenderer'; + +const mockOnClose = jest.fn(); + +const aiTools: api.AiToolDefinition[] = [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/test/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', + }, + { + providerId: 'openai/codex', + tag: 'latest', + name: 'Codex', + url: 'https://openai.com', + binary: 'codex', + pattern: 'init', + injectorImage: 'quay.io/test/codex:latest', + }, +]; + +const aiProviders: api.AiProviderDefinition[] = [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + description: 'Claude AI coding assistant', + }, + { + id: 'openai/codex', + name: 'OpenAI', + publisher: 'OpenAI', + description: 'OpenAI Codex assistant', + }, +]; + +const { renderComponent } = getComponentRenderer(getComponent); + +function getComponent( + isOpen: boolean, + tools: api.AiToolDefinition[], + providers: api.AiProviderDefinition[], +): React.ReactElement { + return ( + + ); +} + +describe('AiToolInfoModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show empty state message when aiTools is empty', () => { + renderComponent(true, [], []); + + expect( + screen.getByText( + 'No AI tools are available. Ask your administrator to configure AI tools in the CheCluster custom resource.', + ), + ).toBeInTheDocument(); + }); + + it('should render tool names as links', () => { + renderComponent(true, aiTools, aiProviders); + + const claudeLink = screen.getByRole('link', { name: 'Claude Code' }); + expect(claudeLink).toHaveAttribute('href', 'https://claude.ai'); + expect(claudeLink).toHaveAttribute('target', '_blank'); + + const codexLink = screen.getByRole('link', { name: 'Codex' }); + expect(codexLink).toHaveAttribute('href', 'https://openai.com'); + expect(codexLink).toHaveAttribute('target', '_blank'); + }); + + it('should show provider descriptions', () => { + renderComponent(true, aiTools, aiProviders); + + expect(screen.getByText(/Claude AI coding assistant/)).toBeInTheDocument(); + expect(screen.getByText(/OpenAI Codex assistant/)).toBeInTheDocument(); + }); + + it('should call onClose when modal is closed', () => { + renderComponent(true, aiTools, aiProviders); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/SelectorModal.spec.tsx new file mode 100644 index 0000000000..8779dd539a --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/SelectorModal.spec.tsx @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2018-2025 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'; +import React from 'react'; + +import { AiToolSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal'; +import getComponentRenderer, { fireEvent, screen } from '@/services/__mocks__/getComponentRenderer'; + +const mockOnToggle = jest.fn(); +const mockOnConfirm = jest.fn(); +const mockOnCancel = jest.fn(); + +const aiTools: api.AiToolDefinition[] = [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/test/claude:latest', + envVarName: 'ANTHROPIC_API_KEY', + }, + { + providerId: 'openai/codex', + tag: 'latest', + name: 'Codex', + url: 'https://openai.com', + binary: 'codex', + pattern: 'init', + injectorImage: 'quay.io/test/codex:latest', + }, +]; + +const aiProviders: api.AiProviderDefinition[] = [ + { + id: 'anthropic/claude', + name: 'Anthropic', + publisher: 'Anthropic', + description: 'Claude AI coding assistant', + }, + { + id: 'openai/codex', + name: 'OpenAI', + publisher: 'OpenAI', + description: 'OpenAI Codex assistant', + }, +]; + +const { renderComponent } = getComponentRenderer(getComponent); + +function getComponent( + isOpen: boolean, + tools: api.AiToolDefinition[], + providers: api.AiProviderDefinition[], + selected: string[], + originSelection: string[], +): React.ReactElement { + return ( + + ); +} + +describe('AiToolSelectorModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show empty state message when aiTools is empty', () => { + renderComponent(true, [], [], [], []); + + expect( + screen.getByText( + 'No AI tools are available. Ask your administrator to configure AI tools in the CheCluster custom resource.', + ), + ).toBeInTheDocument(); + }); + + it('should render checkboxes for each tool', () => { + renderComponent(true, aiTools, aiProviders, [], []); + + expect(screen.getByRole('checkbox', { name: /Claude Code/i })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Codex/i })).toBeInTheDocument(); + }); + + it('should show provider descriptions on checkboxes', () => { + renderComponent(true, aiTools, aiProviders, [], []); + + expect(screen.getByText('Claude AI coding assistant')).toBeInTheDocument(); + expect(screen.getByText('OpenAI Codex assistant')).toBeInTheDocument(); + }); + + it('should have Save button disabled when selection has not changed', () => { + const selected = ['anthropic/claude']; + renderComponent(true, aiTools, aiProviders, selected, selected); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('should have Save button enabled when selection has changed', () => { + renderComponent(true, aiTools, aiProviders, ['anthropic/claude'], []); + + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); + + it('should call onToggle when a checkbox is clicked', () => { + renderComponent(true, aiTools, aiProviders, [], []); + + const checkbox = screen.getByRole('checkbox', { name: /Claude Code/i }); + fireEvent.click(checkbox); + + expect(mockOnToggle).toHaveBeenCalledWith('anthropic/claude'); + }); + + it('should call onCancel when Cancel button is clicked', () => { + renderComponent(true, aiTools, aiProviders, [], []); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('should call onConfirm when Save button is clicked', () => { + renderComponent(true, aiTools, aiProviders, ['anthropic/claude'], []); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(mockOnConfirm).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b2bd4e04ad --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/__tests__/index.spec.tsx @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Action, Dispatch } from 'redux'; + +import { AiToolFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/AiTool'; +import devfileApi from '@/services/devfileApi'; +import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; + +jest.mock('@/pages/WorkspaceDetails/OverviewTab/index.module.css', () => ({ + readonly: 'readonly', + editable: 'editable', +})); + +jest.mock('@/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal', () => ({ + AiToolInfoModal: () =>
, +})); + +jest.mock('@/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal', () => ({ + AiToolSelectorModal: () =>
, +})); + +const mockGetInjectedAiToolIds = jest.fn(); + +jest.mock('@/services/helpers/aiTools', () => ({ + getInjectedAiToolIds: (...args: [Workspace, api.AiToolDefinition[]]) => + mockGetInjectedAiToolIds(...args), + addAiToolToWorkspace: jest.fn(), + removeAiToolFromWorkspace: jest.fn(), +})); + +const mockOnSave = jest.fn(); +const mockDispatch: Dispatch = jest.fn(); + +const mockAiTools: api.AiToolDefinition[] = [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/test/claude-code:latest', + }, + { + providerId: 'openai/codex', + tag: 'latest', + name: 'Codex', + url: 'https://openai.com', + binary: 'codex', + pattern: 'bundle', + injectorImage: 'quay.io/test/codex:latest', + }, +]; + +const mockAiProviders: api.AiProviderDefinition[] = [ + { + id: 'anthropic/claude', + name: 'Claude', + publisher: 'Anthropic', + }, +]; + +function buildMockWorkspace(): Workspace { + const devWorkspace: devfileApi.DevWorkspace = { + apiVersion: 'workspace.devfile.io/v1alpha2', + kind: 'DevWorkspace', + metadata: { + name: 'test-workspace', + namespace: 'test-namespace', + uid: 'test-uid-1234', + }, + spec: { + started: true, + template: { + components: [], + }, + routingClass: 'che', + }, + status: { + devworkspaceId: 'test-workspace-id', + phase: 'Running', + mainUrl: 'https://test-ide.example.com', + }, + }; + + return constructWorkspace(devWorkspace); +} + +describe('AiToolFormGroup', () => { + let mockWorkspace: Workspace; + + beforeEach(() => { + mockWorkspace = buildMockWorkspace(); + mockGetInjectedAiToolIds.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('returns null when aiTools is empty', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + test('renders "None" display name when no tools are selected', () => { + render( + , + ); + + expect(screen.getByText('None')).toBeTruthy(); + }); + + test('shows readonly display when readonly is true', () => { + render( + , + ); + + const readonlySpan = screen.getByText('None'); + expect(readonlySpan.className).toBe('readonly'); + expect(screen.queryByTestId('overview-ai-tool-edit-toggle')).toBeNull(); + }); + + test('shows editable display with edit button when readonly is false', () => { + render( + , + ); + + const editableSpan = screen.getByText('None').closest('span'); + expect(editableSpan?.className).toBe('editable'); + expect(screen.getByTestId('overview-ai-tool-edit-toggle')).toBeTruthy(); + }); + + test('renders single tool name when one tool is injected', () => { + mockGetInjectedAiToolIds.mockReturnValue(['anthropic/claude']); + + render( + , + ); + + expect(screen.getByText('Claude Code')).toBeTruthy(); + }); + + test('renders multiple tool names when multiple tools are injected', () => { + mockGetInjectedAiToolIds.mockReturnValue(['anthropic/claude', 'openai/codex']); + + render( + , + ); + + expect(screen.getByText('Claude Code, Codex')).toBeTruthy(); + }); + + test('renders AI Tool form group label', () => { + render( + , + ); + + expect(screen.getByText('AI Tool')).toBeTruthy(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/index.tsx new file mode 100644 index 0000000000..394b5ed84d --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/index.tsx @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2018-2025 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 { Button, FormGroup, FormGroupLabelHelp } from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import cloneDeep from 'lodash/cloneDeep'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { AiToolInfoModal } from '@/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal'; +import { AiToolSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal'; +import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.css'; +import { + addAiToolToWorkspace, + getInjectedAiToolIds, + removeAiToolFromWorkspace, +} from '@/services/helpers/aiTools'; +import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { selectAiProviders, selectAiTools } from '@/store/AiConfig/selectors'; + +export type Props = MappedProps & { + readonly: boolean; + workspace: Workspace; + onSave: (workspace: Workspace) => Promise; +}; + +export type State = { + isSelectorOpen: boolean; + isInfoOpen: boolean; + selected: string[]; +}; + +class AiToolFormGroup extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { + isSelectorOpen: false, + isInfoOpen: false, + selected: getInjectedAiToolIds(props.workspace, props.aiTools), + }; + } + + public componentDidUpdate(prevProps: Props): void { + const { aiTools, workspace } = this.props; + const newToolIds = getInjectedAiToolIds(workspace, aiTools); + const prevToolIds = getInjectedAiToolIds(prevProps.workspace, prevProps.aiTools); + if (newToolIds.join(',') !== prevToolIds.join(',')) { + this.setState({ selected: newToolIds }); + } + } + + private getDisplayName(toolIds: string[]): string { + const { aiTools } = this.props; + if (toolIds.length === 0) { + return 'None'; + } + return toolIds.map(id => aiTools.find(t => t.providerId === id)?.name ?? id).join(', '); + } + + private handleCancelChanges(): void { + const { aiTools, workspace } = this.props; + this.setState({ + selected: getInjectedAiToolIds(workspace, aiTools), + isSelectorOpen: false, + }); + } + + private async handleConfirmChanges(): Promise { + const { workspace, aiTools, onSave } = this.props; + const currentToolIds = getInjectedAiToolIds(workspace, aiTools); + const { selected } = this.state; + + if (selected.join(',') === currentToolIds.join(',')) { + this.setState({ isSelectorOpen: false }); + return; + } + + let updatedDw = cloneDeep(workspace.ref); + + // Remove tools that are no longer selected + for (const toolId of currentToolIds) { + if (!selected.includes(toolId)) { + updatedDw = removeAiToolFromWorkspace(constructWorkspace(updatedDw), toolId, aiTools); + } + } + + // Add tools that are newly selected + for (const toolId of selected) { + if (!currentToolIds.includes(toolId)) { + updatedDw = addAiToolToWorkspace(constructWorkspace(updatedDw), toolId, aiTools); + } + } + + this.setState({ isSelectorOpen: false }); + await onSave(constructWorkspace(updatedDw)); + } + + public render(): React.ReactNode { + const { aiTools, readonly, workspace } = this.props; + + if (aiTools.length === 0) { + return null; + } + + const { selected, isSelectorOpen, isInfoOpen } = this.state; + + const displayName = this.getDisplayName(selected); + const originSelection = getInjectedAiToolIds(workspace, aiTools); + + return ( + this.setState(prev => ({ isInfoOpen: !prev.isInfoOpen }))} + /> + } + > + {readonly && {displayName}} + {!readonly && ( + + {displayName} + + + )} + { + this.setState(prev => { + const isSelected = prev.selected.includes(toolId); + return { + selected: isSelected + ? prev.selected.filter(id => id !== toolId) + : [...prev.selected, toolId], + }; + }); + }} + onConfirm={() => this.handleConfirmChanges()} + onCancel={() => this.handleCancelChanges()} + /> + this.setState(prev => ({ isInfoOpen: !prev.isInfoOpen }))} + /> + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + aiProviders: selectAiProviders(state), + aiTools: selectAiTools(state), +}); + +const connector = connect(mapStateToProps); +type MappedProps = ConnectedProps; +export { AiToolFormGroup }; +export default connector(AiToolFormGroup); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/__snapshots__/index.spec.tsx.snap index 1994fdc052..1a668a3cfc 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/StorageType/__tests__/__snapshots__/index.spec.tsx.snap @@ -23,7 +23,7 @@ exports[`StorageTypeFormGroup editable storage type screenshot 1`] = ` > { private getInfoModalContent(): React.ReactNode { const { hasEphemeral, hasPerUser, hasPerWorkspace } = this.getExistingTypes(); - const ephemeralTypeDescr = hasEphemeral ? ( + const ephemeralTypeDesc = hasEphemeral ? ( Ephemeral Storage allows for faster I/O but may have limited storage and is not persistent. @@ -119,7 +119,7 @@ class StorageTypeFormGroup extends React.PureComponent { ) : ( '' ); - const perUserTypeDescr = hasPerUser ? ( + const perUserTypeDesc = hasPerUser ? ( Per-user Storage one PVC is provisioned per namespace. All of the workspace's storage (volume mounts) mounted in it on subpaths according to devworkspace ID. @@ -127,7 +127,7 @@ class StorageTypeFormGroup extends React.PureComponent { ) : ( '' ); - const perWorkspaceTypeDescr = hasPerWorkspace ? ( + const perWorkspaceTypeDesc = hasPerWorkspace ? ( Per-workspace Storage a PVC is provisioned for each workspace within the namespace. All of the workspace's storage (volume mounts) are mounted on subpaths within the @@ -141,9 +141,9 @@ class StorageTypeFormGroup extends React.PureComponent { return ( - {perUserTypeDescr} - {perWorkspaceTypeDescr} - {ephemeralTypeDescr} + {perUserTypeDesc} + {perWorkspaceTypeDesc} + {ephemeralTypeDesc} Open documentation page @@ -285,6 +285,7 @@ class StorageTypeFormGroup extends React.PureComponent { fieldId="storage-type" labelHelp={ this.handleInfoToggle()} /> diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css index c2249a67a9..01d01386f8 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css @@ -20,3 +20,8 @@ display: inline-block; padding-top: 8px; } + +.labelHelp { + position: relative; + top: 0.12rem; +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx index 5f972b3d69..7d9f432265 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx @@ -14,6 +14,7 @@ import { Form, PageSection, PageSectionVariants } from '@patternfly/react-core'; import cloneDeep from 'lodash/cloneDeep'; import React from 'react'; +import AiToolFormGroup from '@/pages/WorkspaceDetails/OverviewTab/AiTool'; import GitRepoFormGroup from '@/pages/WorkspaceDetails/OverviewTab/GitRepo'; import { InfrastructureNamespaceFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'; import { ProjectsFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Projects'; @@ -118,6 +119,16 @@ export class OverviewTab extends React.Component { parentStorageType={parentStorageType} onSave={storageType => this.handleStorageSave(storageType)} /> + this.props.onSave(workspace)} + /> diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/buildRows.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/buildRows.tsx index 0d20e99a89..e442d1ea79 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/buildRows.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/buildRows.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem, BackupStatus, DEVWORKSPACE_BACKUP_ANNOTATIONS } from '@eclipse-che/common'; import { diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx index d9d451b34b..e86169a3b4 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupItem } from '@eclipse-che/common'; import { PageSection, PageSectionVariants } from '@patternfly/react-core'; diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/__tests__/index.spec.tsx index 914b0a7668..24f834bdb6 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import userEvent from '@testing-library/user-event'; import React from 'react'; diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/index.tsx index 6b28ab598e..a8901d9918 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/EmptyState/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { Button, diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/index.tsx index fed17ac59c..afcfaeaca4 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { Alert, diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx index 9805b6ceb0..1fd30fd7f9 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/Rows.tsx @@ -10,13 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -import { BackupInfo, BackupStatus } from '@eclipse-che/common'; +import { api, BackupInfo, BackupStatus } from '@eclipse-che/common'; import { Button } from '@patternfly/react-core'; import { ThProps } from '@patternfly/react-table'; import { Location } from 'history'; import React from 'react'; import { Link } from 'react-router-dom'; +import { AiToolIcon } from '@/components/AiToolIcon'; import { BackupStatusBadge } from '@/components/BackupStatusBadge'; import { EditorIcon, getEditorName } from '@/components/EditorIcon'; import { WorkspaceStatusIndicator } from '@/components/Workspace/Status/Indicator'; @@ -37,8 +38,9 @@ export interface RowData { cells: { details: React.ReactNode; editorIcon: React.ReactNode; + aiTool: React.ReactNode; lastModifiedDate: string; - backupStatus?: React.ReactNode; + backupStatus: React.ReactNode; projectsList: string; action: React.ReactNode; actionsDropdown: React.ReactNode; @@ -55,7 +57,9 @@ export function buildRows( selected: string[], sortBy: { index: number; direction: SortDirection }, backupsByWorkspace: Record = {}, - showBackupStatus = true, + aiProviders: api.AiProviderDefinition[] = [], + aiTools: api.AiToolDefinition[] = [], + lastModifiedColumnIndex = 3, ): RowData[] { const rows: RowData[] = []; workspaces @@ -72,7 +76,7 @@ export function buildRows( const editorB = getEditorName({ editors, workspace: workspaceB }) || ''; return sort(editorA, editorB, sortBy.direction); } - if (sortBy.index === 2) { + if (sortBy.index === lastModifiedColumnIndex) { const updatedA = workspaceA.updated || workspaceA.created || 0; const updatedB = workspaceB.updated || workspaceB.created || 0; return sort(updatedA, updatedB, sortBy.direction); @@ -99,7 +103,8 @@ export function buildRows( overviewPageLocation, ideLoaderHref, backupInfo, - showBackupStatus, + aiProviders, + aiTools, ), ); } catch (e) { @@ -126,7 +131,8 @@ export function buildRow( overviewPageLocation: Location, ideLoaderHref: string, backupInfo?: BackupInfo, - showBackupStatus = true, + aiProviders: api.AiProviderDefinition[] = [], + aiTools: api.AiToolDefinition[] = [], ): RowData { if (!workspace.name) { throw new Error('Empty workspace name.'); @@ -153,6 +159,9 @@ export function buildRow( /* editor icon */ const editorIcon = ; + /* injected AI tool */ + const aiTool = ; + /* last modified time */ const lastModifiedMs = workspace.updated; let lastModifiedDate = ''; @@ -217,19 +226,20 @@ export function buildRow( ); /* backup status */ - const backupStatus = showBackupStatus ? ( + const backupStatus = ( - ) : undefined; + ); return { workspaceUID: workspace.uid, cells: { details, editorIcon, + aiTool, lastModifiedDate, backupStatus, projectsList, diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx deleted file mode 100644 index 2847645bf7..0000000000 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/WorkspacesTable/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2018-2025 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 { Divider, PageSection, PageSectionVariants } from '@patternfly/react-core'; -import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import React from 'react'; - -import { getSortParams, RowData, SortDirection } from '@/pages/WorkspacesList/Rows'; - -type ColumnDef = { - title: string; - dataLabel: string; - sortable?: boolean; - screenReaderText?: string; -}; - -type Props = { - columns: ColumnDef[]; - rows: RowData[]; - sortBy: { - index: number; - direction: SortDirection; - }; - onSelect: (isSelected: boolean, rowIndex: number) => void; - onSort: (index: number, direction: SortDirection) => void; - toolbar: React.ReactNode; -}; - -export class WorkspacesTable extends React.PureComponent { - public render(): React.ReactElement { - const { columns, rows, sortBy, onSelect, onSort, toolbar } = this.props; - - return ( - - - {toolbar} - - - - - ), - )} - - - - {rows.map((row, rowIndex) => ( - - - - - {row.cells.backupStatus !== undefined && ( - - )} - - - - - ))} - -
- {columns.map((col, colIndex) => - col.screenReaderText ? ( - - ) : ( - - {col.title} -
onSelect(isSelected, rowIndex), - isSelected: row.isSelected, - isDisabled: row.isDisabled, - }} - /> - {row.cells.details}{row.cells.editorIcon}{row.cells.lastModifiedDate}{row.cells.backupStatus}{row.cells.projectsList}{row.cells.action}{row.cells.actionsDropdown}
-
- ); - } -} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx index 59cee88fb1..2ca4e4dbe7 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/WorkspacesView/index.tsx @@ -10,15 +10,16 @@ * Red Hat, Inc. - initial API and implementation */ -import { BackupConfig, BackupInfo } from '@eclipse-che/common'; +import { api, BackupConfig, BackupInfo } from '@eclipse-che/common'; +import { Divider, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import React from 'react'; import { NavigateFunction } from 'react-router-dom'; import NothingFoundEmptyState from '@/pages/WorkspacesList/EmptyState/NothingFound'; import NoWorkspacesEmptyState from '@/pages/WorkspacesList/EmptyState/NoWorkspaces'; -import { buildRows, RowData, SortDirection } from '@/pages/WorkspacesList/Rows'; +import { buildRows, getSortParams, RowData, SortDirection } from '@/pages/WorkspacesList/Rows'; import WorkspacesListToolbar from '@/pages/WorkspacesList/WorkspacesView/Toolbar'; -import { WorkspacesTable } from '@/pages/WorkspacesList/WorkspacesView/WorkspacesTable'; import { BrandingData } from '@/services/bootstrap/branding.constant'; import devfileApi from '@/services/devfileApi'; import { buildGettingStartedLocation } from '@/services/helpers/location'; @@ -29,6 +30,8 @@ type Props = { workspaces: Workspace[]; editors: devfileApi.Devfile[]; backupsByWorkspace: Record; + aiProviders: api.AiProviderDefinition[]; + aiTools: api.AiToolDefinition[]; branding: BrandingData; navigate: NavigateFunction; }; @@ -45,41 +48,27 @@ type State = { }; export class WorkspacesView extends React.PureComponent { - private getColumns() { - const showBackupStatus = !!this.props.backupConfig?.registry; - const columns = [ - { title: 'Name', dataLabel: 'Name', sortable: true }, - { title: 'Editor', dataLabel: 'Editor', sortable: true }, - { title: 'Last Modified', dataLabel: 'Last Modified', sortable: true }, - ...(showBackupStatus ? [{ title: 'Backup Status', dataLabel: 'Backup Status' }] : []), - { title: 'Project(s)', dataLabel: 'Project(s)' }, - { title: '', dataLabel: ' ', screenReaderText: 'Open' }, - { title: '', dataLabel: ' ', screenReaderText: 'Open' }, - { title: '', dataLabel: ' ', screenReaderText: 'Actions' }, - ]; - return columns; - } - constructor(props: Props) { super(props); const filtered = this.props.workspaces.map(workspace => workspace.uid); + const lastModifiedIndex = this.props.aiTools.length > 0 ? 3 : 2; this.state = { filtered, selected: [], isSelectedAll: false, rows: [], sortBy: { - index: 2, // Last Modified column + index: lastModifiedIndex, direction: 'asc', }, }; } private buildRows(): RowData[] { - const { backupConfig, backupsByWorkspace, editors, workspaces } = this.props; + const { aiProviders, aiTools, backupsByWorkspace, editors, workspaces } = this.props; const { filtered, selected, sortBy } = this.state; - const showBackupStatus = !!backupConfig?.registry; + const lastModifiedColumnIndex = aiTools.length > 0 ? 3 : 2; return buildRows( workspaces, @@ -89,7 +78,9 @@ export class WorkspacesView extends React.PureComponent { selected, sortBy, backupsByWorkspace, - showBackupStatus, + aiProviders, + aiTools, + lastModifiedColumnIndex, ); } @@ -206,16 +197,76 @@ export class WorkspacesView extends React.PureComponent { emptyState = ; } + const showAiColumn = this.props.aiTools.length > 0; + const showBackupStatus = !!this.props.backupConfig?.registry; + const columns = [ + { title: 'Name', dataLabel: 'Name', sortable: true }, + { title: 'Editor', dataLabel: 'Editor', sortable: true }, + ...(showAiColumn ? [{ title: 'AI Provider(s)', dataLabel: 'AI Provider(s)' }] : []), + { title: 'Last Modified', dataLabel: 'Last Modified', sortable: true }, + ...(showBackupStatus ? [{ title: 'Backup Status', dataLabel: 'Backup Status' }] : []), + { title: 'Project(s)', dataLabel: 'Project(s)' }, + ]; + return ( <> - this.handleSelect(isSelected, rowIndex)} - onSort={(index, direction) => this.handleSort(index, direction)} - toolbar={toolbar} - /> + + + {toolbar} + + + + + ))} + + + + {rows.map((row, rowIndex) => ( + + + + {showAiColumn && } + + {showBackupStatus && } + + + + + ))} + +
+ {columns.map((col, colIndex) => ( + this.handleSort(index, direction), + ) + : undefined + } + > + {col.title} + + +
this.handleSelect(isSelected, rowIndex), + isSelected: row.isSelected, + isDisabled: row.isDisabled, + }} + /> + {row.cells.details}{row.cells.editorIcon}{row.cells.aiTool}{row.cells.lastModifiedDate}{row.cells.backupStatus}{row.cells.projectsList}{row.cells.action}{row.cells.actionsDropdown}
+
{emptyState} ); diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/__tests__/index.spec.tsx index e79bcb681d..0e348bcc5d 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/__tests__/index.spec.tsx @@ -204,6 +204,8 @@ function getComponent(_workspaces = workspaces): React.ReactElement { return ( ; branding: BrandingData; @@ -82,8 +84,16 @@ export default class WorkspacesList extends React.PureComponent { } public render(): React.ReactElement { - const { backupConfig, backupsByWorkspace, branding, editors, navigate, workspaces } = - this.props; + const { + aiProviders, + aiTools, + backupConfig, + backupsByWorkspace, + branding, + editors, + navigate, + workspaces, + } = this.props; const { workspace: workspacesDocsLink } = branding.docs; const { viewMode } = this.state; @@ -123,9 +133,11 @@ export default class WorkspacesList extends React.PureComponent { ) : ( { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchAiRegistry', () => { + it('should fetch AI registry', async () => { + const data = { providers: [], tools: [], defaultAiProviders: [] }; + mockGet.mockResolvedValueOnce({ data }); + + const result = await fetchAiRegistry(); + expect(result).toEqual(data); + }); + + it('should throw error when fetch fails', async () => { + mockGet.mockRejectedValueOnce({ + code: '500', + message: 'error message', + } as AxiosError); + + let errorMessage: string | undefined; + try { + await fetchAiRegistry(); + } catch (err) { + errorMessage = common.helpers.errors.getMessage(err); + } + + expect(errorMessage).toEqual('Failed to fetch AI registry. error message'); + }); + }); + + describe('fetchAiProviderKeyStatus', () => { + it('should fetch provider key status', async () => { + mockGet.mockResolvedValueOnce({ data: ['google/gemini/latest'] }); + + const result = await fetchAiProviderKeyStatus('test-namespace'); + expect(result).toEqual(['google/gemini/latest']); + }); + + it('should throw error when fetch fails', async () => { + mockGet.mockRejectedValueOnce({ + code: '500', + message: 'error message', + } as AxiosError); + + let errorMessage: string | undefined; + try { + await fetchAiProviderKeyStatus('test-namespace'); + } catch (err) { + errorMessage = common.helpers.errors.getMessage(err); + } + + expect(errorMessage).toEqual('Failed to fetch AI provider key status. error message'); + }); + }); + + describe('saveAiProviderKey', () => { + it('should save the provider key', async () => { + mockPost.mockResolvedValueOnce({ data: undefined }); + + await expect( + saveAiProviderKey('test-namespace', 'gemini-cli', 'GEMINI_API_KEY', 'test-api-key'), + ).resolves.not.toThrow(); + }); + + it('should throw error when save fails', async () => { + mockPost.mockRejectedValueOnce({ + code: '500', + message: 'error message', + } as AxiosError); + + let errorMessage: string | undefined; + try { + await saveAiProviderKey('test-namespace', 'gemini-cli', 'GEMINI_API_KEY', 'test-api-key'); + } catch (err) { + errorMessage = common.helpers.errors.getMessage(err); + } + + expect(errorMessage).toEqual('Failed to save AI provider key. error message'); + }); + }); + + describe('deleteAiProviderKey', () => { + it('should delete the provider key', async () => { + mockDelete.mockResolvedValueOnce({ data: undefined }); + + await expect(deleteAiProviderKey('test-namespace', 'gemini-cli')).resolves.not.toThrow(); + }); + + it('should throw error when delete fails', async () => { + mockDelete.mockRejectedValueOnce({ + code: '500', + message: 'error message', + } as AxiosError); + + let errorMessage: string | undefined; + try { + await deleteAiProviderKey('test-namespace', 'gemini-cli'); + } catch (err) { + errorMessage = common.helpers.errors.getMessage(err); + } + + expect(errorMessage).toEqual('Failed to delete AI provider key. error message'); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/services/backend-client/aiConfigApi.ts b/packages/dashboard-frontend/src/services/backend-client/aiConfigApi.ts new file mode 100644 index 0000000000..51fbf67c4b --- /dev/null +++ b/packages/dashboard-frontend/src/services/backend-client/aiConfigApi.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2025 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, helpers } from '@eclipse-che/common'; + +import { AxiosWrapper } from '@/services/axios-wrapper/axiosWrapper'; +import { dashboardBackendPrefix } from '@/services/backend-client/const'; + +export async function fetchAiRegistry(): Promise { + try { + const response = await AxiosWrapper.createToRetryMissedBearerTokenError().get( + `${dashboardBackendPrefix}/ai-registry`, + ); + return response.data; + } catch (e) { + throw new Error(`Failed to fetch AI registry. ${helpers.errors.getMessage(e)}`); + } +} + +export async function fetchAiProviderKeyStatus(namespace: string): Promise { + try { + const response = await AxiosWrapper.createToRetryMissedBearerTokenError().get( + `${dashboardBackendPrefix}/namespace/${namespace}/ai-provider-key`, + ); + return response.data; + } catch (e) { + throw new Error(`Failed to fetch AI provider key status. ${helpers.errors.getMessage(e)}`); + } +} + +export async function saveAiProviderKey( + namespace: string, + toolId: string, + envVarName: string, + apiKey: string, +): Promise { + try { + await AxiosWrapper.createToRetryMissedBearerTokenError().post( + `${dashboardBackendPrefix}/namespace/${namespace}/ai-provider-key`, + { toolId, envVarName, apiKey }, + ); + } catch (e) { + throw new Error(`Failed to save AI provider key. ${helpers.errors.getMessage(e)}`); + } +} + +export async function deleteAiProviderKey(namespace: string, toolId: string): Promise { + try { + await AxiosWrapper.createToRetryMissedBearerTokenError().delete( + `${dashboardBackendPrefix}/namespace/${namespace}/ai-provider-key/${encodeURIComponent(toolId)}`, + ); + } catch (e) { + throw new Error(`Failed to delete AI provider key. ${helpers.errors.getMessage(e)}`); + } +} diff --git a/packages/dashboard-frontend/src/services/backend-client/websocketClient/__tests__/index.spec.ts b/packages/dashboard-frontend/src/services/backend-client/websocketClient/__tests__/index.spec.ts index baf3a0e2a5..d7f7fd4515 100644 --- a/packages/dashboard-frontend/src/services/backend-client/websocketClient/__tests__/index.spec.ts +++ b/packages/dashboard-frontend/src/services/backend-client/websocketClient/__tests__/index.spec.ts @@ -22,12 +22,29 @@ console.log = jest.fn(); console.warn = jest.fn(); describe('websocketClient', () => { + const clients: WebsocketClient[] = []; + + function createClient(): WebsocketClient { + const client = new WebsocketClient(); + clients.push(client); + return client; + } + beforeEach(() => { // do not use fake timers, because it causes issues with jest-websocket-mock // jest.useFakeTimers(); }); afterEach(() => { + // Close all ReconnectingWebSocket instances before cleaning up mock servers + // to prevent stale reconnection attempts from leaking into the next test. + clients.forEach(client => { + const stream = (client as unknown as Record).websocketStream as + | { close: () => void } + | undefined; + stream?.close(); + }); + clients.length = 0; WS.clean(); jest.clearAllMocks(); }); @@ -37,7 +54,7 @@ describe('websocketClient', () => { const handleConnectionOpen = jest.fn(); it('should connect to websocket and call listener for the OPEN event once', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); websocketClient.addConnectionEventListener(ConnectionEvent.OPEN, handleConnectionOpen); @@ -50,7 +67,7 @@ describe('websocketClient', () => { }); it('should reconnect to websocket when server closes the connection', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); websocketClient.addConnectionEventListener(ConnectionEvent.OPEN, handleConnectionOpen); @@ -74,7 +91,7 @@ describe('websocketClient', () => { }); it('should reconnect to websocket when receives "error" event', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const handleConnectionOpen = jest.fn(); @@ -103,7 +120,7 @@ describe('websocketClient', () => { }); it('should return the same connection ready promise when called', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); websocketClient.addConnectionEventListener(ConnectionEvent.OPEN, handleConnectionOpen); @@ -123,7 +140,7 @@ describe('websocketClient', () => { describe('getting connection closed', () => { it('should call the listener for the CLOSE event', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const handleConnectionClose = jest.fn(); @@ -144,7 +161,7 @@ describe('websocketClient', () => { describe('getting connection error', () => { it('should call listener for the ERROR event', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const handleConnectionError = jest.fn(); @@ -163,7 +180,7 @@ describe('websocketClient', () => { describe('past event notification', () => { it('should not call the listener', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); websocketClient.connect(); @@ -180,7 +197,7 @@ describe('websocketClient', () => { }); it('should call the listener', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); websocketClient.connect(); @@ -203,7 +220,7 @@ describe('websocketClient', () => { }); it('should remove the listener', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const handleConnectionOpen = jest.fn(); @@ -234,7 +251,7 @@ describe('websocketClient', () => { describe('adding subscriptions', () => { it('should add a subscription and send the subscribe message', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const addSubscriptionSpy = jest.spyOn( @@ -262,7 +279,7 @@ describe('websocketClient', () => { }); it('should add a subscription but not send the subscribe message #1', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const addSubscriptionSpy = jest.spyOn( @@ -283,7 +300,7 @@ describe('websocketClient', () => { }); it('should add a subscription but not send the subscribe message #2', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const addSubscriptionSpy = jest.spyOn( @@ -307,7 +324,7 @@ describe('websocketClient', () => { describe('removing subscriptions', () => { it('should remove a subscription and send the unsubscribe message', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const removeSubscriptionSpy = jest.spyOn( @@ -333,7 +350,7 @@ describe('websocketClient', () => { }); it('should remove a subscription but not send the unsubscribe message #1', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const removeSubscriptionSpy = jest.spyOn( @@ -352,7 +369,7 @@ describe('websocketClient', () => { }); it('should remove a subscription but not send the unsubscribe message #2', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const removeSubscriptionSpy = jest.spyOn( @@ -374,7 +391,7 @@ describe('websocketClient', () => { describe('resubscribing', () => { it('should get existing subscriptions and send messages', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const getSubscriptionsSpy = jest.spyOn( @@ -410,7 +427,7 @@ describe('websocketClient', () => { }); it('should not send any messages #1', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const getSubscriptionsSpy = jest.spyOn( @@ -432,7 +449,7 @@ describe('websocketClient', () => { }); it('should not send any messages #1', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const getSubscriptionsSpy = jest.spyOn( @@ -458,7 +475,7 @@ describe('websocketClient', () => { describe('handling data messages', () => { it('should add a channel message listener', () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const addListenerSpy = jest.spyOn((websocketClient as any).messageHandler, 'addListener'); @@ -470,7 +487,7 @@ describe('websocketClient', () => { }); it('should notify a channel listener', async () => { - const websocketClient = new WebsocketClient(); + const websocketClient = createClient(); const serverMock = new WS('ws://localhost/dashboard/api/websocket'); const notifyListenersSpy = jest.spyOn( diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index 38e660cc97..159540bc7c 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -35,6 +35,8 @@ import { ResourceFetcherService } from '@/services/resource-fetcher'; import { Workspace } from '@/services/workspace-adapter'; import { hasLoginPage, isForbidden, isUnauthorized } from '@/services/workspace-client/helpers'; import { RootState } from '@/store'; +import { aiConfigActionCreators } from '@/store/AiConfig'; +import { selectAiConfigEnabled } from '@/store/AiConfig/selectors'; import { bannerAlertActionCreators } from '@/store/BannerAlert'; import { brandingActionCreators } from '@/store/Branding'; import { clusterConfigActionCreators, selectDashboardFavicon } from '@/store/ClusterConfig'; @@ -123,6 +125,7 @@ export default class Bootstrap { }), this.fetchSshKeys(), this.fetchWorkspacePreferences(), + this.fetchAiRegistry().then(() => this.fetchAiProviderKeyStatus()), ]); const errors = results @@ -462,6 +465,29 @@ export default class Bootstrap { await requestSshKeys()(this.store.dispatch, this.store.getState, undefined); } + private async fetchAiRegistry(): Promise { + const { requestAiRegistry } = aiConfigActionCreators; + try { + await requestAiRegistry()(this.store.dispatch, this.store.getState, undefined); + } catch (e) { + console.warn('Unable to fetch AI registry.', e); + } + } + + private async fetchAiProviderKeyStatus(): Promise { + const state = this.store.getState(); + const enabled = selectAiConfigEnabled(state); + if (!enabled) { + return; + } + const { requestAiProviderKeyStatus } = aiConfigActionCreators; + try { + await requestAiProviderKeyStatus()(this.store.dispatch, this.store.getState, undefined); + } catch (e) { + console.warn('Unable to fetch AI provider key status.', e); + } + } + private async fetchWorkspacePreferences(): Promise { await workspacePreferencesActionCreators.requestPreferences()( this.store.dispatch, diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts new file mode 100644 index 0000000000..aceb8c8da0 --- /dev/null +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts @@ -0,0 +1,1121 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { api } from '@eclipse-che/common'; + +import { + addAiToolToWorkspace, + ADMIN_MANAGEABLE_ATTRIBUTE, + getInjectedAiToolIds, + getInjectedAiToolNames, + PENDING_CLEANUP_ANNOTATION, + removeAiToolFromWorkspace, + sanitizeStaleAiTools, + stripImageTag, + toolCommandIds, + updateOutdatedAiTools, +} from '@/services/helpers/aiTools'; +import { constructWorkspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; + +const CLAUDE_TOOL: api.AiToolDefinition = { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'Claude Code', + url: 'https://claude.ai/code', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude-code:next', + envVarName: 'ANTHROPIC_API_KEY', +}; + +const GEMINI_TOOL: api.AiToolDefinition = { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', + setupCommand: 'mkdir -p /tmp/gemini-home/.gemini', +}; + +const ALL_TOOLS = [CLAUDE_TOOL, GEMINI_TOOL]; + +function buildWorkspaceWithComponents( + components: Array>, + commands?: Array>, + events?: Record, + annotations?: Record, +) { + const builder = new DevWorkspaceBuilder() + .withName('test-wksp') + .withNamespace('user-che') + .withTemplate({ + components: components as never, + commands: commands as never, + events, + }); + if (annotations) { + builder.withMetadata({ annotations }); + } + const devWorkspace = builder.build(); + return constructWorkspace(devWorkspace); +} + +describe('aiTools', () => { + describe('stripImageTag', () => { + it('should strip tag from image reference', () => { + expect(stripImageTag('quay.io/example/claude-code:next')).toBe('quay.io/example/claude-code'); + }); + + it('should strip digest from image reference', () => { + expect(stripImageTag('quay.io/example/claude-code@sha256:abc123')).toBe( + 'quay.io/example/claude-code', + ); + }); + + it('should preserve image with no tag or digest', () => { + expect(stripImageTag('quay.io/example/claude-code')).toBe('quay.io/example/claude-code'); + }); + + it('should not strip port number from registry', () => { + expect(stripImageTag('registry:5000/repo/image:v1')).toBe('registry:5000/repo/image'); + }); + }); + + describe('toolCommandIds', () => { + it('should return correct command IDs for a tool', () => { + const ids = toolCommandIds('claude-code'); + expect(ids).toEqual({ + install: 'install-claude-code', + symlink: 'symlink-claude-code', + run: 'run-claude-code', + cleanup: 'cleanup-claude-code', + }); + }); + }); + + describe('getInjectedAiToolIds', () => { + it('should detect an injected init-pattern tool by image', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code:next' }, + }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual(['anthropic/claude']); + }); + + it('should detect an injected bundle-pattern tool by image', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'gemini-cli-injector', + container: { image: 'quay.io/example/gemini-cli:next' }, + }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual(['google/gemini']); + }); + + it('should detect multiple injected tools', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code:next' }, + }, + { + name: 'gemini-cli-injector', + container: { image: 'quay.io/example/gemini-cli:next' }, + }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual([ + 'anthropic/claude', + 'google/gemini', + ]); + }); + + it('should detect a tool even when the tag differs (tag-agnostic)', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code:v2.0' }, + }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual(['anthropic/claude']); + }); + + it('should detect a tool with a digest instead of a tag', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { + image: 'quay.io/example/claude-code@sha256:abcdef1234567890', + }, + }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual(['anthropic/claude']); + }); + + it('should return empty array when no AI tool is injected', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual([]); + }); + + it('should return empty array when components are empty', () => { + const workspace = buildWorkspaceWithComponents([]); + expect(getInjectedAiToolIds(workspace, ALL_TOOLS)).toEqual([]); + }); + }); + + describe('getInjectedAiToolNames', () => { + it('should return the display name of the injected tool', () => { + const workspace = buildWorkspaceWithComponents([ + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code:next' }, + }, + ]); + expect(getInjectedAiToolNames(workspace, ALL_TOOLS)).toEqual(['Claude Code']); + }); + + it('should return empty array when no tool is injected', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + expect(getInjectedAiToolNames(workspace, ALL_TOOLS)).toEqual([]); + }); + }); + + describe('addAiToolToWorkspace', () => { + it('should add init-pattern tool components, commands, and events', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const template = patched.spec.template; + + // Injector component added + const injector = template.components?.find(c => c.name === 'claude-code-injector'); + expect(injector).toBeDefined(); + expect(injector?.container?.image).toBe(CLAUDE_TOOL.injectorImage); + expect(injector?.container?.command).toEqual(['/bin/sh']); + expect(injector?.container?.args?.[1]).toContain( + 'rm -f /injected-tools/bin/claude /injected-tools/bin/claude-bin 2>/dev/null;', + ); + expect(injector?.container?.args?.[1]).toContain( + 'mkdir -p /injected-tools/bin && cp /usr/local/bin/claude /injected-tools/bin/claude', + ); + + // Volume added + const volume = template.components?.find(c => c.name === 'injected-tools'); + expect(volume).toBeDefined(); + + // Install command (preStart apply) + const installCmd = template.commands?.find( + (c: { id?: string }) => c.id === 'install-claude-code', + ); + expect(installCmd).toBeDefined(); + + // Symlink command (postStart exec) + const symlinkCmd = template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-claude-code', + ); + expect(symlinkCmd).toBeDefined(); + + // Events + expect(template.events?.preStart).toContain('install-claude-code'); + expect(template.events?.postStart).toContain('symlink-claude-code'); + }); + + it('should add bundle-pattern tool with correct copy command', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'google/gemini', ALL_TOOLS); + const injector = patched.spec.template.components?.find( + c => c.name === 'gemini-cli-injector', + ); + expect(injector?.container?.command).toEqual(['/bin/sh']); + expect(injector?.container?.args).toEqual([ + '-c', + 'rm -rf /injected-tools/gemini-cli 2>/dev/null; cp -a /opt/gemini-cli/. /injected-tools/gemini-cli/', + ]); + }); + + it('should mark injector component with admin-manageable attribute', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const injector = patched.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { attributes?: Record } | undefined; + expect(injector?.attributes?.[ADMIN_MANAGEABLE_ATTRIBUTE]).toBe(true); + }); + + it('should mark injected-tools volume with admin-manageable attribute', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const volume = patched.spec.template.components?.find(c => c.name === 'injected-tools') as + | { attributes?: Record } + | undefined; + expect(volume?.attributes?.[ADMIN_MANAGEABLE_ATTRIBUTE]).toBe(true); + }); + + it('should be idempotent — re-injection does not duplicate components', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + // Inject twice + const firstPatch = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const firstWorkspace = constructWorkspace(firstPatch); + const secondPatch = addAiToolToWorkspace(firstWorkspace, 'anthropic/claude', ALL_TOOLS); + + const injectors = secondPatch.spec.template.components?.filter( + c => c.name === 'claude-code-injector', + ); + expect(injectors).toHaveLength(1); + + const volumes = secondPatch.spec.template.components?.filter( + c => c.name === 'injected-tools', + ); + expect(volumes).toHaveLength(1); + + const installCmds = secondPatch.spec.template.commands?.filter( + (c: { id?: string }) => c.id === 'install-claude-code', + ); + expect(installCmds).toHaveLength(1); + + expect( + secondPatch.spec.template.events?.preStart?.filter( + (e: string) => e === 'install-claude-code', + ), + ).toHaveLength(1); + }); + + it('should not add postStart commands when no editor component exists', () => { + // Workspace with only a volume component (no container) + const workspace = buildWorkspaceWithComponents([ + { name: 'shared-data', volume: { size: '1Gi' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + + // Install command should exist (preStart) + const installCmd = patched.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'install-claude-code', + ); + expect(installCmd).toBeDefined(); + + // Symlink command should NOT exist (no editor for postStart exec) + const symlinkCmd = patched.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-claude-code', + ); + expect(symlinkCmd).toBeUndefined(); + + expect(patched.spec.template.events?.postStart).toBeUndefined(); + }); + + it('should add PATH env var to editor container', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const editorComp = patched.spec.template.components?.find(c => c.name === 'editor') as + | { container?: { env?: Array<{ name: string; value: string }> } } + | undefined; + + const pathEnv = editorComp?.container?.env?.find(e => e.name === 'PATH'); + expect(pathEnv).toBeDefined(); + expect(pathEnv?.value).toContain('/injected-tools/bin'); + }); + + it('should prepend to existing PATH env var without duplicating', () => { + const workspace = buildWorkspaceWithComponents([ + { + name: 'editor', + container: { + image: 'che-code:latest', + env: [{ name: 'PATH', value: '/usr/bin:/usr/local/bin' }], + }, + }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const editorComp = patched.spec.template.components?.find(c => c.name === 'editor') as + | { container?: { env?: Array<{ name: string; value: string }> } } + | undefined; + + const pathEnv = editorComp?.container?.env?.find(e => e.name === 'PATH'); + expect(pathEnv?.value).toBe('/injected-tools/bin:/usr/bin:/usr/local/bin'); + + // Inject again — should not duplicate the /injected-tools/bin prefix + const secondPatch = addAiToolToWorkspace( + constructWorkspace(patched), + 'anthropic/claude', + ALL_TOOLS, + ); + const editorComp2 = secondPatch.spec.template.components?.find(c => c.name === 'editor') as + | { container?: { env?: Array<{ name: string; value: string }> } } + | undefined; + const pathEnv2 = editorComp2?.container?.env?.find(e => e.name === 'PATH'); + expect(pathEnv2?.value).toBe('/injected-tools/bin:/usr/bin:/usr/local/bin'); + }); + + it('should add injected-tools volume mount to editor container', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const editorComp = patched.spec.template.components?.find(c => c.name === 'editor') as + | { container?: { volumeMounts?: Array<{ name: string; path: string }> } } + | undefined; + + const mount = editorComp?.container?.volumeMounts?.find(vm => vm.name === 'injected-tools'); + expect(mount).toBeDefined(); + expect(mount?.path).toBe('/injected-tools'); + }); + + it('should include setupCommand for bundle-pattern tools', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'google/gemini', ALL_TOOLS); + const symlinkCmd = patched.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-gemini-cli', + ) as { exec?: { commandLine?: string } } | undefined; + + expect(symlinkCmd?.exec?.commandLine).toContain('mkdir -p /tmp/gemini-home/.gemini'); + expect(symlinkCmd?.exec?.commandLine).toContain('ln -sf'); + expect(symlinkCmd?.exec?.commandLine).toContain('/injected-tools/gemini-cli/bin/gemini'); + }); + + it('should throw for unknown tool ID', () => { + const workspace = buildWorkspaceWithComponents([]); + expect(() => addAiToolToWorkspace(workspace, 'unknown/tool', ALL_TOOLS)).toThrow( + 'Unknown AI tool: unknown/tool', + ); + }); + + it('should not mutate the original workspace', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + const originalRef = workspace.ref; + const originalComponentCount = originalRef.spec.template.components?.length ?? 0; + + addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + + expect(originalRef.spec.template.components?.length).toBe(originalComponentCount); + }); + }); + + describe('removeAiToolFromWorkspace', () => { + it('should remove injector component, commands, events and add cleanup command', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + // First add, then remove + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + + expect( + removed.spec.template.components?.find(c => c.name === 'claude-code-injector'), + ).toBeUndefined(); + expect( + removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'install-claude-code', + ), + ).toBeUndefined(); + expect( + removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-claude-code', + ), + ).toBeUndefined(); + expect( + removed.spec.template.commands?.find((c: { id?: string }) => c.id === 'run-claude-code'), + ).toBeUndefined(); + expect(removed.spec.template.events?.preStart).not.toContain('install-claude-code'); + expect(removed.spec.template.events?.postStart).not.toContain('symlink-claude-code'); + + // Cleanup command should be added to remove stale binaries on next start + const cleanupCmd = removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ) as { exec?: { commandLine?: string } } | undefined; + expect(cleanupCmd).toBeDefined(); + expect(cleanupCmd?.exec?.commandLine).toContain('rm -f /injected-tools/bin/claude'); + expect(removed.spec.template.events?.postStart).toContain('cleanup-claude-code'); + }); + + it('should preserve the injected-tools volume (shared by other tools)', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + + // Volume is intentionally preserved + expect( + removed.spec.template.components?.find(c => c.name === 'injected-tools'), + ).toBeDefined(); + }); + + it('should preserve other components and commands', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + + expect(removed.spec.template.components?.find(c => c.name === 'editor')).toBeDefined(); + }); + + it('should add cleanup command when tool was never added', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const removed = removeAiToolFromWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + // Original component preserved + expect(removed.spec.template.components).toHaveLength(1); + // Cleanup command added + const cleanupCmd = removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ); + expect(cleanupCmd).toBeDefined(); + }); + + it('should remove stale commands from previous injection and add cleanup', () => { + const workspace = buildWorkspaceWithComponents( + [ + { name: 'editor', container: { image: 'che-code:latest' } }, + { name: 'claude-code-injector', container: { image: 'old-image:v1' } }, + ], + [ + { id: 'install-claude-code', apply: { component: 'claude-code-injector' } }, + { id: 'symlink-claude-code', exec: { component: 'editor' } }, + { id: 'run-claude-code', exec: { component: 'editor' } }, + ], + { + preStart: ['install-claude-code'], + postStart: ['symlink-claude-code'], + }, + ); + + const removed = removeAiToolFromWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + + expect( + removed.spec.template.components?.find(c => c.name === 'claude-code-injector'), + ).toBeUndefined(); + // Only cleanup command should remain + expect(removed.spec.template.commands).toHaveLength(1); + expect(removed.spec.template.commands?.[0]).toHaveProperty('id', 'cleanup-claude-code'); + expect(removed.spec.template.events?.preStart).toHaveLength(0); + expect(removed.spec.template.events?.postStart).toEqual(['cleanup-claude-code']); + }); + + it('should run cleanup command in the background to avoid blocking workspace start', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + + const cleanupCmd = removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ) as { exec?: { commandLine?: string } } | undefined; + expect(cleanupCmd?.exec?.commandLine).toMatch(/^nohup sh -c '.*' >\/dev\/null 2>&1 &$/); + }); + + it('should add cleanup that removes bundle directory for bundle-pattern tools', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const added = addAiToolToWorkspace(workspace, 'google/gemini', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'google/gemini', ALL_TOOLS); + + const cleanupCmd = removed.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-gemini-cli', + ) as { exec?: { commandLine?: string } } | undefined; + expect(cleanupCmd).toBeDefined(); + expect(cleanupCmd?.exec?.commandLine).toContain('rm -rf /injected-tools/gemini-cli'); + expect(cleanupCmd?.exec?.commandLine).toContain('rm -f /injected-tools/bin/gemini'); + }); + + it('should remove cleanup command when re-adding a previously removed tool', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + // Add, remove (adds cleanup), then re-add (should remove cleanup) + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + const removedWorkspace = constructWorkspace(removed); + const reAdded = addAiToolToWorkspace(removedWorkspace, 'anthropic/claude', ALL_TOOLS); + + // Cleanup command should be gone + expect( + reAdded.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ), + ).toBeUndefined(); + expect(reAdded.spec.template.events?.postStart).not.toContain('cleanup-claude-code'); + + // Injector should be back + expect( + reAdded.spec.template.components?.find(c => c.name === 'claude-code-injector'), + ).toBeDefined(); + }); + + it('should set pending-cleanup annotation when adding cleanup command', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const addedWorkspace = constructWorkspace(added); + const removed = removeAiToolFromWorkspace(addedWorkspace, 'anthropic/claude', ALL_TOOLS); + + expect(removed.metadata?.annotations?.[PENDING_CLEANUP_ANNOTATION]).toBe('claude-code'); + }); + + it('should append to existing pending-cleanup annotation', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const withBoth = addAiToolToWorkspace( + addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS), + 'google/gemini', + ALL_TOOLS, + ); + const withBothWs = constructWorkspace(withBoth); + const removedClaude = removeAiToolFromWorkspace(withBothWs, 'anthropic/claude', ALL_TOOLS); + const removedClaudeWs = constructWorkspace(removedClaude); + const removedBoth = removeAiToolFromWorkspace(removedClaudeWs, 'google/gemini', ALL_TOOLS); + + const annotation = removedBoth.metadata?.annotations?.[PENDING_CLEANUP_ANNOTATION] ?? ''; + const slugs = annotation.split(','); + expect(slugs).toContain('claude-code'); + expect(slugs).toContain('gemini-cli'); + }); + }); + + describe('sanitizeStaleAiTools', () => { + it('should return null when no stale tools exist', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + expect(sanitizeStaleAiTools(added, ALL_TOOLS)).toBeNull(); + }); + + it('should return null when there are no admin-manageable components', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { name: 'some-injector', container: { image: 'unknown-image:v1' } }, + ]); + expect(sanitizeStaleAiTools(workspace.ref, ALL_TOOLS)).toBeNull(); + }); + + it('should remove admin-manageable components with unrecognized images', () => { + // Simulate a workspace with a stale tool: admin-manageable but image not in allTools + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'old-tool-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/removed-tool:v1' }, + }, + { + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + }, + ]); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + expect( + result!.spec.template.components?.find(c => c.name === 'old-tool-injector'), + ).toBeUndefined(); + // Volume should also be removed since no recognized injectors remain + expect( + result!.spec.template.components?.find(c => c.name === 'injected-tools'), + ).toBeUndefined(); + // Editor should be preserved + expect(result!.spec.template.components?.find(c => c.name === 'editor')).toBeDefined(); + }); + + it('should remove injected-tools volume mount and PATH from editor when all tools removed', () => { + const workspace = buildWorkspaceWithComponents([ + { + name: 'editor', + container: { + image: 'che-code:latest', + volumeMounts: [{ name: 'injected-tools', path: '/injected-tools' }], + env: [ + { + name: 'PATH', + value: + '/injected-tools/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }, + ], + }, + }, + { + name: 'old-tool-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/removed-tool:v1' }, + }, + { + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + }, + ]); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + + // Volume component removed + expect( + result!.spec.template.components?.find(c => c.name === 'injected-tools'), + ).toBeUndefined(); + + // Editor volume mount cleaned + const editorComp = result!.spec.template.components?.find(c => c.name === 'editor') as + | { + container?: { + volumeMounts?: Array<{ name: string }>; + env?: Array<{ name: string; value: string }>; + }; + } + | undefined; + expect( + editorComp?.container?.volumeMounts?.find(vm => vm.name === 'injected-tools'), + ).toBeUndefined(); + + // PATH env var removed (was just the default with injected-tools prepended) + expect(editorComp?.container?.env?.find(e => e.name === 'PATH')).toBeUndefined(); + }); + + it('should preserve volume when a recognized tool still exists', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + // Add a recognized tool, then manually add a stale one + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + const components = added.spec.template.components as unknown as Array< + Record + >; + components.push({ + name: 'old-tool-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/removed-tool:v1' }, + }); + + const result = sanitizeStaleAiTools(added, ALL_TOOLS); + expect(result).not.toBeNull(); + // Stale component removed + expect( + result!.spec.template.components?.find(c => c.name === 'old-tool-injector'), + ).toBeUndefined(); + // Volume preserved (recognized tool still present) + expect( + result!.spec.template.components?.find(c => c.name === 'injected-tools'), + ).toBeDefined(); + // Recognized injector preserved + expect( + result!.spec.template.components?.find(c => c.name === 'claude-code-injector'), + ).toBeDefined(); + }); + + it('should remove stale commands and events', () => { + const workspace = buildWorkspaceWithComponents( + [ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'old-tool-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/removed-tool:v1' }, + }, + ], + [ + { id: 'install-old-tool', apply: { component: 'old-tool-injector' } }, + { id: 'symlink-old-tool', exec: { component: 'editor' } }, + { id: 'other-command', exec: { component: 'editor' } }, + ], + { + preStart: ['install-old-tool'], + postStart: ['symlink-old-tool'], + }, + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + // Stale commands removed + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'install-old-tool'), + ).toBeUndefined(); + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'symlink-old-tool'), + ).toBeUndefined(); + // Non-stale command preserved + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'other-command'), + ).toBeDefined(); + // Stale events removed + expect(result!.spec.template.events?.preStart).toHaveLength(0); + expect(result!.spec.template.events?.postStart).toHaveLength(0); + }); + + it('should recognize tools even when tags differ', () => { + // Workspace has a recognized tool but with a different tag — should NOT be removed + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/claude-code:v2.0' }, + }, + ]); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).toBeNull(); + }); + + it('should remove orphaned cleanup commands whose injector no longer exists', () => { + // Cleanup commands run in background on postStart, so by the next start + // they have already executed and can be safely removed + const workspace = buildWorkspaceWithComponents( + [{ name: 'editor', container: { image: 'che-code:latest' } }], + [ + { + id: 'cleanup-claude-code', + exec: { component: 'editor', commandLine: 'rm -f /injected-tools/bin/claude' }, + }, + { id: 'other-command', exec: { component: 'editor' } }, + ], + { + postStart: ['cleanup-claude-code'], + }, + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + // Cleanup command removed + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ), + ).toBeUndefined(); + // Other command preserved + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'other-command'), + ).toBeDefined(); + expect(result!.spec.template.events?.postStart).toHaveLength(0); + }); + + it('should remove all orphaned commands including cleanup', () => { + const workspace = buildWorkspaceWithComponents( + [{ name: 'editor', container: { image: 'che-code:latest' } }], + [ + { id: 'install-claude-code', apply: { component: 'claude-code-injector' } }, + { id: 'symlink-claude-code', exec: { component: 'editor' } }, + { + id: 'cleanup-claude-code', + exec: { component: 'editor', commandLine: 'rm -f /injected-tools/bin/claude' }, + }, + { id: 'other-command', exec: { component: 'editor' } }, + ], + { + preStart: ['install-claude-code'], + postStart: ['symlink-claude-code', 'cleanup-claude-code'], + }, + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + // All orphaned commands removed (install, symlink, cleanup) + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'install-claude-code', + ), + ).toBeUndefined(); + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-claude-code', + ), + ).toBeUndefined(); + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ), + ).toBeUndefined(); + // Other command preserved + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'other-command'), + ).toBeDefined(); + // Events cleaned + expect(result!.spec.template.events?.preStart).toHaveLength(0); + expect(result!.spec.template.events?.postStart).toHaveLength(0); + }); + + it('should keep cleanup command when pending-cleanup annotation is present', () => { + // First cold start after tool removal: annotation marks cleanup as pending, + // so the command must survive this start cycle to execute during postStart. + const workspace = buildWorkspaceWithComponents( + [{ name: 'editor', container: { image: 'che-code:latest' } }], + [ + { + id: 'cleanup-claude-code', + exec: { component: 'editor', commandLine: 'rm -f /injected-tools/bin/claude' }, + }, + { id: 'other-command', exec: { component: 'editor' } }, + ], + { + postStart: ['cleanup-claude-code'], + }, + { + [PENDING_CLEANUP_ANNOTATION]: 'claude-code', + }, + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + // Cleanup command preserved + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ), + ).toBeDefined(); + // Other command preserved + expect( + result!.spec.template.commands?.find((c: { id?: string }) => c.id === 'other-command'), + ).toBeDefined(); + // postStart still has cleanup + expect(result!.spec.template.events?.postStart).toContain('cleanup-claude-code'); + // Annotation cleared so next start will remove cleanup + expect(result!.metadata?.annotations?.[PENDING_CLEANUP_ANNOTATION]).toBeUndefined(); + }); + + it('should remove cleanup command on second start after annotation was cleared', () => { + // Second cold start: annotation was cleared on first start, so cleanup is + // now a completed orphan and should be removed. + const workspace = buildWorkspaceWithComponents( + [{ name: 'editor', container: { image: 'che-code:latest' } }], + [ + { + id: 'cleanup-claude-code', + exec: { component: 'editor', commandLine: 'rm -f /injected-tools/bin/claude' }, + }, + ], + { + postStart: ['cleanup-claude-code'], + }, + // No annotation — cleanup already executed + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + // Cleanup command removed + expect( + result!.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'cleanup-claude-code', + ), + ).toBeUndefined(); + expect(result!.spec.template.events?.postStart).toHaveLength(0); + }); + + it('should not remove tool commands when their injector component exists', () => { + const workspace = buildWorkspaceWithComponents( + [ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/claude-code:next' }, + }, + ], + [ + { id: 'install-claude-code', apply: { component: 'claude-code-injector' } }, + { id: 'symlink-claude-code', exec: { component: 'editor' } }, + ], + { + preStart: ['install-claude-code'], + postStart: ['symlink-claude-code'], + }, + ); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).toBeNull(); + }); + }); + + describe('updateOutdatedAiTools', () => { + it('should return null when all injected tools are up-to-date', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + const added = addAiToolToWorkspace(workspace, 'anthropic/claude', ALL_TOOLS); + + const result = updateOutdatedAiTools(added, ALL_TOOLS); + expect(result).toBeNull(); + }); + + it('should return null when no admin-manageable components exist', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const result = updateOutdatedAiTools(workspace.ref, ALL_TOOLS); + expect(result).toBeNull(); + }); + + it('should update a tool when the tag has changed in the registry', () => { + // Simulate a workspace with an old tag + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/claude-code:old-tag' }, + volumeMounts: [{ name: 'injected-tools', path: '/injected-tools' }], + }, + { + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + }, + ]); + + const result = updateOutdatedAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + + // The injector should now have the current image from the registry + const injector = result!.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + expect(injector).toBeDefined(); + expect(injector!.container!.image).toBe('quay.io/example/claude-code:next'); + }); + + it('should not update unrecognized tools (handled by sanitize)', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'unknown-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/unknown-tool:v1' }, + }, + ]); + + const result = updateOutdatedAiTools(workspace.ref, ALL_TOOLS); + expect(result).toBeNull(); + }); + + it('should select the best tool by tag priority when multiple tools share a providerId', () => { + const toolsWithMultipleVersions: api.AiToolDefinition[] = [ + { ...CLAUDE_TOOL, tag: '1.0.0', injectorImage: 'quay.io/example/claude-code:1.0.0' }, + { ...CLAUDE_TOOL, tag: 'next', injectorImage: 'quay.io/example/claude-code:next' }, + { ...CLAUDE_TOOL, tag: 'latest', injectorImage: 'quay.io/example/claude-code:latest' }, + ]; + + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/claude-code:old' }, + }, + { + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + }, + ]); + + const result = updateOutdatedAiTools(workspace.ref, toolsWithMultipleVersions); + expect(result).not.toBeNull(); + + // "next" has highest priority + const injector = result!.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + expect(injector!.container!.image).toBe('quay.io/example/claude-code:next'); + }); + + it('should select the highest semver when no named tags are present', () => { + const toolsWithSemver: api.AiToolDefinition[] = [ + { ...CLAUDE_TOOL, tag: '1.0.0', injectorImage: 'quay.io/example/claude-code:1.0.0' }, + { ...CLAUDE_TOOL, tag: '3.21', injectorImage: 'quay.io/example/claude-code:3.21' }, + { ...CLAUDE_TOOL, tag: '2.55', injectorImage: 'quay.io/example/claude-code:2.55' }, + ]; + + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { image: 'quay.io/example/claude-code:old' }, + }, + { + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + }, + ]); + + const result = updateOutdatedAiTools(workspace.ref, toolsWithSemver); + expect(result).not.toBeNull(); + + // 3.21 is the highest semver + const injector = result!.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + expect(injector!.container!.image).toBe('quay.io/example/claude-code:3.21'); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/services/helpers/aiTools.ts b/packages/dashboard-frontend/src/services/helpers/aiTools.ts new file mode 100644 index 0000000000..b5736f1215 --- /dev/null +++ b/packages/dashboard-frontend/src/services/helpers/aiTools.ts @@ -0,0 +1,796 @@ +/* + * Copyright (c) 2018-2025 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 { + V1alpha2DevWorkspaceSpecTemplateCommands, + V1alpha2DevWorkspaceSpecTemplateComponents, +} from '@devfile/api'; +import { api } from '@eclipse-che/common'; + +import devfileApi from '@/services/devfileApi'; +import { Workspace } from '@/services/workspace-adapter'; + +type DevWorkspaceComponent = V1alpha2DevWorkspaceSpecTemplateComponents; +type DevWorkspaceCommand = V1alpha2DevWorkspaceSpecTemplateCommands; + +/** Attribute key used to mark dashboard-managed AI tool components and commands. */ +export const ADMIN_MANAGEABLE_ATTRIBUTE = 'che.eclipse.org/admin-manageable'; + +/** + * Annotation key used to track pending cleanup commands. + * Stores a comma-separated list of tool slugs whose cleanup commands + * have not yet executed. After one start cycle (the cleanup runs during + * postStart), sanitizeStaleAiTools removes the slug from this annotation + * and deletes the cleanup command. + */ +export const PENDING_CLEANUP_ANNOTATION = 'che.eclipse.org/pending-ai-cleanup'; + +/** + * Strips the tag or digest suffix from a container image reference. + * e.g. 'quay.io/example/claude-code:next' → 'quay.io/example/claude-code' + * 'quay.io/example/claude-code@sha256:abc' → 'quay.io/example/claude-code' + */ +export function stripImageTag(image: string): string { + // Remove digest first (@sha256:...), then tag (:...) + const atIdx = image.indexOf('@'); + if (atIdx !== -1) { + return image.substring(0, atIdx); + } + const colonIdx = image.lastIndexOf(':'); + // Avoid stripping port numbers (e.g. 'registry:5000/repo') + if (colonIdx !== -1 && !image.substring(colonIdx).includes('/')) { + return image.substring(0, colonIdx); + } + return image; +} + +/** + * Extracts a Kubernetes-compatible slug from the injector image name. + * e.g. 'quay.io/example/claude-code:next' → 'claude-code' + */ +export function getToolSlug(tool: api.AiToolDefinition): string { + const imagePath = tool.injectorImage.split(':')[0]; + const lastSegment = imagePath.split('/').pop(); + return lastSegment ?? tool.binary; +} + +/** Command IDs used for a given tool slug */ +export function toolCommandIds(slug: string): { + install: string; + symlink: string; + run: string; + cleanup: string; +} { + return { + install: `install-${slug}`, + symlink: `symlink-${slug}`, + run: `run-${slug}`, + cleanup: `cleanup-${slug}`, + }; +} + +/** + * Returns all toolIds of AI tools injected into this workspace. + * Detects by matching the injectorImage from any known tool in `allTools`. + */ +export function getInjectedAiToolIds( + workspace: Workspace, + allTools: api.AiToolDefinition[], +): string[] { + const components: Array<{ name?: string; container?: { image?: string } }> = + (workspace.ref.spec?.template?.components as Array<{ + name?: string; + container?: { image?: string }; + }>) ?? []; + const ids: string[] = []; + for (const comp of components) { + const image: string = comp.container?.image ?? ''; + const tool = allTools.find(t => stripImageTag(t.injectorImage) === stripImageTag(image)); + if (tool && !ids.includes(tool.providerId)) { + ids.push(tool.providerId); + } + } + return ids; +} + +/** + * Returns the display names of all injected AI tools. + */ +export function getInjectedAiToolNames( + workspace: Workspace, + allTools: api.AiToolDefinition[], +): string[] { + const toolIds = getInjectedAiToolIds(workspace, allTools); + return toolIds + .map(id => allTools.find(t => t.providerId === id)?.name) + .filter((name): name is string => name !== undefined); +} + +/** + * Finds the name of the first user container (not an injector). + */ +function findEditorComponentName( + components: Array<{ name?: string; container?: object; volume?: object }>, +): string | undefined { + for (const c of components) { + if (c.container && c.name && !c.name.endsWith('-injector')) { + return c.name; + } + } + return undefined; +} + +/** + * Builds the postStart command line that sets up PATH and (for bundle tools) a symlink. + * + * init pattern: binary is already at /injected-tools/bin/ — no symlink needed. + * bundle pattern: binary is at /injected-tools//bin/ — symlink into /injected-tools/bin/. + */ +function buildPostStartCommandLine(tool: api.AiToolDefinition): string { + const { binary, pattern, setupCommand } = tool; + const slug = getToolSlug(tool); + + const exportLine = 'export PATH="/injected-tools/bin:$PATH"'; + const pathSetup = + `for rc in "$HOME/.bashrc" "$HOME/.profile"; do ` + + `grep -q injected-tools "$rc" 2>/dev/null || echo '${exportLine}' >> "$rc" 2>/dev/null; ` + + `done; true`; + + let mainCmd: string; + if (pattern === 'init') { + // Binary already at /injected-tools/bin/, just ensure PATH + mainCmd = pathSetup; + } else { + // Symlink bundle binary (and bundled node if present) into /injected-tools/bin/ + const bundleDir = `/injected-tools/${slug}/bin`; + const symlinkTarget = `${bundleDir}/${binary}`; + const nodeSymlink = `test -f ${bundleDir}/node && ln -sf ${bundleDir}/node /injected-tools/bin/node; true`; + mainCmd = `mkdir -p /injected-tools/bin && ln -sf ${symlinkTarget} /injected-tools/bin/${binary} && ${nodeSymlink} && ${pathSetup}`; + } + + // SECURITY NOTE: setupCommand is sourced from an admin-managed ConfigMap. + // If this ever accepts user input, it must be sanitized to prevent command injection. + // setupCommand is best-effort (e.g. creating config dirs); it must not block + // the critical symlink/PATH setup even if it fails (e.g. read-only $HOME). + return setupCommand ? `{ ${setupCommand}; } 2>/dev/null; ${mainCmd}` : mainCmd; +} + +/** + * Builds the cleanup command line that removes stale binaries from the shared volume + * when a tool is removed from the workspace. Runs in the background (nohup ... &) + * so it never blocks or freezes workspace startup. If a removal fails, the command + * is idempotent and will retry on the next start. + */ +function buildCleanupCommandLine(tool: api.AiToolDefinition): string { + const { binary, pattern } = tool; + const slug = getToolSlug(tool); + + const parts: string[] = []; + parts.push(`rm -f /injected-tools/bin/${binary}`); + parts.push(`rm -f /injected-tools/bin/${binary}-bin`); + if (pattern === 'bundle') { + parts.push(`rm -rf /injected-tools/${slug}`); + } + // Run in background so it never blocks workspace startup + return `nohup sh -c '${parts.join(' && ')}' >/dev/null 2>&1 &`; +} + +/** + * Returns a cloned DevWorkspace spec with the given AI tool injector added. + * + * preStart → install-{toolId} apply command (copies binary via init container) + * postStart → symlink-{toolId} exec command (creates /injected-tools/bin symlink + PATH) + * + * Accepts either a Workspace adapter or a raw DevWorkspace object. + */ +export function addAiToolToWorkspace( + workspaceOrDevWorkspace: Workspace | devfileApi.DevWorkspace, + toolId: string, + allTools: api.AiToolDefinition[], +): devfileApi.DevWorkspace { + const tool = allTools.find(t => t.providerId === toolId); + if (!tool) { + throw new Error(`Unknown AI tool: ${toolId}`); + } + + const slug = getToolSlug(tool); + const raw = + 'ref' in workspaceOrDevWorkspace ? workspaceOrDevWorkspace.ref : workspaceOrDevWorkspace; + const cloned: devfileApi.DevWorkspace = JSON.parse(JSON.stringify(raw)); + const template = cloned.spec.template; + template.components = template.components ?? []; + template.commands = template.commands ?? []; + + const compName = `${slug}-injector`; + const { + install: installCmdId, + symlink: symlinkCmdId, + run: runCmdId, + cleanup: cleanupCmdId, + } = toolCommandIds(slug); + + // Remove existing injector for this tool (idempotent) + template.components = template.components.filter(c => c.name !== compName); + template.commands = template.commands.filter( + (c: { id?: string }) => + c.id !== installCmdId && c.id !== symlinkCmdId && c.id !== runCmdId && c.id !== cleanupCmdId, + ); + if (template.events?.preStart) { + template.events.preStart = template.events.preStart.filter((e: string) => e !== installCmdId); + } + if (template.events?.postStart) { + template.events.postStart = template.events.postStart.filter( + (e: string) => e !== symlinkCmdId && e !== cleanupCmdId, + ); + } + + // Ensure injected-tools volume exists + if (!template.components.some(c => c.name === 'injected-tools')) { + template.components.push({ + name: 'injected-tools', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + volume: { size: '256Mi' }, + } as unknown as DevWorkspaceComponent); + } + + // Add injector component (init container) + if (tool.pattern === 'init') { + template.components.push({ + name: compName, + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { + image: tool.injectorImage, + command: ['/bin/sh'], + args: [ + '-c', + `rm -f /injected-tools/bin/${tool.binary} /injected-tools/bin/${tool.binary}-bin 2>/dev/null; mkdir -p /injected-tools/bin && cp /usr/local/bin/${tool.binary} /injected-tools/bin/${tool.binary} && { test -f /usr/local/bin/${tool.binary}-bin && cp /usr/local/bin/${tool.binary}-bin /injected-tools/bin/${tool.binary}-bin || true; }`, + ], + memoryLimit: '512Mi', + mountSources: false, + volumeMounts: [{ name: 'injected-tools', path: '/injected-tools' }], + }, + } as unknown as DevWorkspaceComponent); + } else { + template.components.push({ + name: compName, + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: { + image: tool.injectorImage, + command: ['/bin/sh'], + args: [ + '-c', + `rm -rf /injected-tools/${slug} 2>/dev/null; cp -a /opt/${slug}/. /injected-tools/${slug}/`, + ], + memoryLimit: '512Mi', + mountSources: false, + volumeMounts: [{ name: 'injected-tools', path: '/injected-tools' }], + }, + } as unknown as DevWorkspaceComponent); + } + + // preStart: apply command runs the init container + template.commands.push({ + id: installCmdId, + apply: { component: compName }, + } as unknown as DevWorkspaceCommand); + + // postStart: exec command creates symlink and updates PATH + const editorName = findEditorComponentName( + template.components as Array<{ name?: string; container?: object; volume?: object }>, + ); + + // Ensure the editor/tooling container mounts the injected-tools volume and has PATH set + if (editorName) { + const editorComp = template.components.find(c => c.name === editorName) as + | { + name: string; + container?: { + volumeMounts?: Array<{ name: string; path: string }>; + env?: Array<{ name: string; value: string }>; + }; + } + | undefined; + if (editorComp?.container) { + editorComp.container.volumeMounts = editorComp.container.volumeMounts ?? []; + if (!editorComp.container.volumeMounts.some(vm => vm.name === 'injected-tools')) { + editorComp.container.volumeMounts.push({ name: 'injected-tools', path: '/injected-tools' }); + } + + // Prepend /injected-tools/bin to PATH at the container env level + editorComp.container.env = editorComp.container.env ?? []; + const pathEntry = editorComp.container.env.find(e => e.name === 'PATH'); + if (pathEntry) { + if (!pathEntry.value.includes('/injected-tools/bin')) { + pathEntry.value = `/injected-tools/bin:${pathEntry.value}`; + } + } else { + // $(PATH) substitution cannot be used here — it is resolved by the operator + // after container startup, causing entrypoint failures. The fallback value + // matches the default PATH of the che-code editor image. + editorComp.container.env.push({ + name: 'PATH', + value: '/injected-tools/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); + } + } + } + + if (editorName) { + template.commands.push({ + id: symlinkCmdId, + exec: { + component: editorName, + commandLine: buildPostStartCommandLine(tool), + workingDir: '${CHE_PROJECTS_ROOT}', + label: `Set up ${tool.name}`, + }, + } as unknown as DevWorkspaceCommand); + } + + // Events + if (!template.events) { + template.events = {}; + } + + template.events.preStart = template.events.preStart ?? []; + if (!template.events.preStart.includes(installCmdId)) { + template.events.preStart.push(installCmdId); + } + + if (editorName) { + template.events.postStart = template.events.postStart ?? []; + if (!template.events.postStart.includes(symlinkCmdId)) { + template.events.postStart.push(symlinkCmdId); + } + } + + return cloned; +} + +/** + * Returns a cloned DevWorkspace spec with the given AI tool fully removed. + * + * Accepts either a Workspace adapter or a raw DevWorkspace object. + */ +export function removeAiToolFromWorkspace( + workspaceOrDevWorkspace: Workspace | devfileApi.DevWorkspace, + toolId: string, + allTools: api.AiToolDefinition[], +): devfileApi.DevWorkspace { + const tool = allTools.find(t => t.providerId === toolId); + const slug = tool ? getToolSlug(tool) : toolId; + const raw = + 'ref' in workspaceOrDevWorkspace ? workspaceOrDevWorkspace.ref : workspaceOrDevWorkspace; + const cloned: devfileApi.DevWorkspace = JSON.parse(JSON.stringify(raw)); + const template = cloned.spec.template; + const compName = `${slug}-injector`; + const { + install: installCmdId, + symlink: symlinkCmdId, + run: runCmdId, + cleanup: cleanupCmdId, + } = toolCommandIds(slug); + + if (template.components) { + template.components = template.components.filter(c => c.name !== compName); + } + + if (template.commands) { + template.commands = template.commands.filter( + (c: { id?: string }) => + c.id !== installCmdId && + c.id !== symlinkCmdId && + c.id !== runCmdId && + c.id !== cleanupCmdId, + ); + } + + if (template.events?.preStart) { + template.events.preStart = template.events.preStart.filter((e: string) => e !== installCmdId); + } + + if (template.events?.postStart) { + template.events.postStart = template.events.postStart.filter( + (e: string) => e !== symlinkCmdId && e !== cleanupCmdId, + ); + } + + // Add a postStart cleanup command to remove stale binaries from the shared volume. + // The cleanup command survives one cold start so it can execute during postStart. + // sanitizeStaleAiTools tracks this via the PENDING_CLEANUP_ANNOTATION: on the first + // cold start the annotation is present so the command is kept; after execution, + // the next start removes both the command and the annotation. + if (tool) { + const editorName = findEditorComponentName( + (template.components ?? []) as Array<{ + name?: string; + container?: object; + volume?: object; + }>, + ); + if (editorName) { + const cleanupLine = buildCleanupCommandLine(tool); + template.commands = template.commands ?? []; + template.commands.push({ + id: cleanupCmdId, + exec: { + component: editorName, + commandLine: cleanupLine, + workingDir: '${CHE_PROJECTS_ROOT}', + label: `Clean up ${tool.name}`, + }, + } as unknown as DevWorkspaceCommand); + + template.events = template.events ?? {}; + template.events.postStart = template.events.postStart ?? []; + template.events.postStart.push(cleanupCmdId); + + // Mark this slug as pending cleanup so sanitizeStaleAiTools + // keeps the cleanup command alive for one start cycle. + cloned.metadata = cloned.metadata ?? {}; + cloned.metadata.annotations = cloned.metadata.annotations ?? {}; + const existing = cloned.metadata.annotations[PENDING_CLEANUP_ANNOTATION] ?? ''; + const slugs = existing ? existing.split(',').filter(Boolean) : []; + if (!slugs.includes(slug)) { + slugs.push(slug); + } + cloned.metadata.annotations[PENDING_CLEANUP_ANNOTATION] = slugs.join(','); + } + } + + return cloned; +} + +/** + * Checks whether a component is an admin-manageable injector component. + */ +function isAdminManageable(comp: { attributes?: Record }): boolean { + return comp.attributes?.[ADMIN_MANAGEABLE_ATTRIBUTE] === true; +} + +/** + * Returns true if the component's container image matches any known tool (tag-agnostic). + */ +function isRecognizedToolImage( + comp: { container?: { image?: string } }, + allTools: api.AiToolDefinition[], +): boolean { + const image = comp.container?.image ?? ''; + if (image === '') { + return false; + } + return allTools.some(t => stripImageTag(t.injectorImage) === stripImageTag(image)); +} + +/** + * Removes all admin-manageable AI tool components whose injector image is + * no longer recognized (not in `allTools`), along with their commands and events. + * Also removes orphaned tool commands (install/symlink/run/cleanup) whose + * corresponding `*-injector` component no longer exists in the spec. + * Cleanup commands run in the background on postStart, so by the next + * workspace start they have already executed and can be safely removed. + * + * This prevents stale or outdated injector containers from breaking workspace starts. + * Volume components with the admin-manageable attribute are preserved if at least one + * recognized tool still exists; otherwise they are removed too. + * + * Returns null if no changes were made, or the patched DevWorkspace if stale tools were removed. + */ +export function sanitizeStaleAiTools( + devWorkspace: devfileApi.DevWorkspace, + allTools: api.AiToolDefinition[], +): devfileApi.DevWorkspace | null { + const template = devWorkspace.spec.template; + const components = (template.components ?? []) as Array<{ + name?: string; + attributes?: Record; + container?: { image?: string }; + volume?: object; + }>; + + // Find admin-manageable injector components (with containers) whose image is unrecognized + const staleComponents = components.filter( + c => isAdminManageable(c) && c.container && !isRecognizedToolImage(c, allTools), + ); + + // Collect slugs of all injector components that will remain after stale removal + const staleNames = new Set(staleComponents.map(c => c.name).filter(Boolean)); + const remainingInjectorSlugs = new Set( + components + .filter(c => c.name?.endsWith('-injector') && !staleNames.has(c.name)) + .map(c => c.name!.replace(/-injector$/, '')), + ); + + // Find orphaned tool commands whose injector component no longer exists. + const commands = (template.commands ?? []) as Array<{ id?: string }>; + const toolCmdPattern = /^(install|symlink|run)-(.+)$/; + const cleanupCmdPattern = /^cleanup-(.+)$/; + const orphanedCommandIds = new Set(); + for (const cmd of commands) { + if (!cmd.id) { + continue; + } + const match = cmd.id.match(toolCmdPattern); + if (match && !remainingInjectorSlugs.has(match[2])) { + orphanedCommandIds.add(cmd.id); + } + } + + // Handle cleanup commands separately using the pending-cleanup annotation. + // When a tool is removed, the cleanup command is added along with an annotation + // marking the slug as "pending". On the first cold start, the annotation is + // present so the cleanup command is kept (it needs to execute). The annotation + // is cleared for that slug so that on the next start the cleanup command is + // removed as a completed orphan. + const pendingAnnotation = devWorkspace.metadata?.annotations?.[PENDING_CLEANUP_ANNOTATION] ?? ''; + const pendingSlugs = new Set(pendingAnnotation.split(',').filter(Boolean)); + const cleanupSlugsToRemove: string[] = []; + const cleanupSlugsToKeep: string[] = []; + + for (const cmd of commands) { + if (!cmd.id) { + continue; + } + const match = cmd.id.match(cleanupCmdPattern); + if (match && !remainingInjectorSlugs.has(match[1])) { + const slug = match[1]; + if (pendingSlugs.has(slug)) { + // Cleanup has not yet executed — keep the command, clear the annotation + cleanupSlugsToKeep.push(slug); + } else { + // Cleanup already executed (or was never pending) — remove the command + orphanedCommandIds.add(cmd.id); + cleanupSlugsToRemove.push(slug); + } + } + } + + const hasCleanupChanges = cleanupSlugsToKeep.length > 0 || cleanupSlugsToRemove.length > 0; + + if (staleComponents.length === 0 && orphanedCommandIds.size === 0 && !hasCleanupChanges) { + return null; + } + + const cloned: devfileApi.DevWorkspace = JSON.parse(JSON.stringify(devWorkspace)); + const clonedTemplate = cloned.spec.template; + clonedTemplate.components = clonedTemplate.components ?? []; + clonedTemplate.commands = clonedTemplate.commands ?? []; + + // Remove stale injector components + clonedTemplate.components = ( + clonedTemplate.components as unknown as Array<{ + name?: string; + attributes?: Record; + container?: { image?: string }; + volume?: object; + }> + ).filter(c => !staleNames.has(c.name)) as unknown as DevWorkspaceComponent[]; + + // Collect command IDs to remove: stale component commands + orphaned commands + const staleCommandIds = new Set(orphanedCommandIds); + for (const cmd of clonedTemplate.commands as Array<{ + id?: string; + apply?: { component?: string }; + }>) { + if (cmd.apply && cmd.id && staleNames.has(cmd.apply.component)) { + staleCommandIds.add(cmd.id); + } + } + + for (const staleName of staleNames) { + if (staleName && staleName.endsWith('-injector')) { + const slug = staleName.replace(/-injector$/, ''); + const cmdIds = toolCommandIds(slug); + staleCommandIds.add(cmdIds.install); + staleCommandIds.add(cmdIds.symlink); + staleCommandIds.add(cmdIds.run); + staleCommandIds.add(cmdIds.cleanup); + } + } + + // Remove stale commands + clonedTemplate.commands = (clonedTemplate.commands as unknown as Array<{ id?: string }>).filter( + c => !staleCommandIds.has(c.id ?? ''), + ) as unknown as DevWorkspaceCommand[]; + + // Clean up events + if (clonedTemplate.events?.preStart) { + clonedTemplate.events.preStart = clonedTemplate.events.preStart.filter( + (e: string) => !staleCommandIds.has(e), + ); + } + if (clonedTemplate.events?.postStart) { + clonedTemplate.events.postStart = clonedTemplate.events.postStart.filter( + (e: string) => !staleCommandIds.has(e), + ); + } + + // Update the pending-cleanup annotation: remove slugs whose cleanup command + // was kept (they will execute this start cycle) so next start removes them. + if (cleanupSlugsToKeep.length > 0 || cleanupSlugsToRemove.length > 0) { + cloned.metadata = cloned.metadata ?? {}; + cloned.metadata.annotations = cloned.metadata.annotations ?? {}; + const updatedSlugs = [...pendingSlugs].filter( + s => !cleanupSlugsToKeep.includes(s) && !cleanupSlugsToRemove.includes(s), + ); + if (updatedSlugs.length > 0) { + cloned.metadata.annotations[PENDING_CLEANUP_ANNOTATION] = updatedSlugs.join(','); + } else { + delete cloned.metadata.annotations[PENDING_CLEANUP_ANNOTATION]; + } + } + + // Remove admin-manageable volume if no recognized injectors remain + const hasRecognizedInjectors = ( + clonedTemplate.components as Array<{ + attributes?: Record; + container?: { image?: string }; + }> + ).some(c => isAdminManageable(c) && c.container); + + if (!hasRecognizedInjectors) { + clonedTemplate.components = ( + clonedTemplate.components as unknown as Array<{ + name?: string; + attributes?: Record; + volume?: object; + }> + ).filter(c => !(isAdminManageable(c) && c.volume)) as unknown as DevWorkspaceComponent[]; + + // Remove injected-tools volume mounts and PATH env vars from editor containers + for (const comp of clonedTemplate.components as unknown as Array<{ + name?: string; + container?: { + volumeMounts?: Array<{ name: string; path: string }>; + env?: Array<{ name: string; value: string }>; + }; + }>) { + if (!comp.container) { + continue; + } + if (comp.container.volumeMounts) { + comp.container.volumeMounts = comp.container.volumeMounts.filter( + vm => vm.name !== 'injected-tools', + ); + } + if (comp.container.env) { + const pathEntry = comp.container.env.find(e => e.name === 'PATH'); + if (pathEntry?.value.includes('/injected-tools/bin:')) { + pathEntry.value = pathEntry.value.replace('/injected-tools/bin:', ''); + // If PATH is now just the default, remove the override entirely + if (pathEntry.value === '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin') { + comp.container.env = comp.container.env.filter(e => e.name !== 'PATH'); + } + } + } + } + } + + return cloned; +} + +/** + * Updates admin-manageable AI tool components whose injector image is recognized + * (same image name without tag) but outdated (different tag from the registry). + * + * For each outdated component the tool is removed and re-added using the + * current registry definition, which updates the injector image, commands, + * and lifecycle hooks to the latest version. + * + * When multiple tools share the same providerId the best match is selected + * by tag priority: "next" > "latest" > highest semver > first in list. + * + * Returns null if no updates were needed, or the patched DevWorkspace. + */ +export function updateOutdatedAiTools( + devWorkspace: devfileApi.DevWorkspace, + allTools: api.AiToolDefinition[], +): devfileApi.DevWorkspace | null { + const components = (devWorkspace.spec.template.components ?? []) as Array<{ + name?: string; + attributes?: Record; + container?: { image?: string }; + }>; + + // Collect providerIds of outdated injectors + const outdatedProviderIds: string[] = []; + + for (const comp of components) { + if (!isAdminManageable(comp) || !comp.container?.image) { + continue; + } + const image = comp.container.image; + const imageBase = stripImageTag(image); + + // Find tools whose base image matches (recognized) but full image differs (outdated) + const matchingTools = allTools.filter(t => stripImageTag(t.injectorImage) === imageBase); + if (matchingTools.length === 0) { + // Not recognized — handled by sanitizeStaleAiTools + continue; + } + + // If any matching tool has the exact same full image, this component is up-to-date + if (matchingTools.some(t => t.injectorImage === image)) { + continue; + } + + // Component is outdated — pick the best replacement by providerId + const providerId = matchingTools[0].providerId; + if (!outdatedProviderIds.includes(providerId)) { + outdatedProviderIds.push(providerId); + } + } + + if (outdatedProviderIds.length === 0) { + return null; + } + + let result: devfileApi.DevWorkspace = JSON.parse(JSON.stringify(devWorkspace)); + + for (const providerId of outdatedProviderIds) { + // Select the best tool for this provider + const candidateTools = allTools.filter(t => t.providerId === providerId); + const bestTool = selectBestTool(candidateTools); + + // Remove old, add new — pass a filtered tools list so addAiToolToWorkspace + // picks the best version (find() returns the first match by providerId). + const toolsWithBest = [bestTool, ...allTools.filter(t => t.providerId !== providerId)]; + result = removeAiToolFromWorkspace(result, providerId, allTools); + result = addAiToolToWorkspace(result, bestTool.providerId, toolsWithBest); + } + + return result; +} + +/** + * Selects the best tool from a list of candidates sharing the same providerId. + * Priority: "next" > "latest" > highest semver > first in list. + */ +function selectBestTool(candidates: api.AiToolDefinition[]): api.AiToolDefinition { + if (candidates.length === 1) { + return candidates[0]; + } + + const priority: Record = { next: 3, latest: 2 }; + + let best = candidates[0]; + let bestScore = tagScore(best.tag, priority); + + for (let i = 1; i < candidates.length; i++) { + const score = tagScore(candidates[i].tag, priority); + if (score > bestScore) { + best = candidates[i]; + bestScore = score; + } + } + + return best; +} + +/** + * Returns a numeric score for a tag to enable comparison. + * Named tags ("next", "latest") get fixed high scores. + * Semver-like tags get a score derived from their numeric components. + */ +function tagScore(tag: string, priority: Record): number { + if (priority[tag] !== undefined) { + // Named tags always rank above any semver version + return 1_000_000 + priority[tag]; + } + // Try to parse as semver (e.g. "3.21", "2.55", "1.0.0") + const parts = tag.split('.').map(Number); + if (parts.length > 0 && parts.every(p => !isNaN(p))) { + // Weight: major * 10000 + minor * 100 + patch + return (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0); + } + return 0; +} diff --git a/packages/dashboard-frontend/src/services/helpers/factoryFlow/__tests__/buildFactoryParams.spec.ts b/packages/dashboard-frontend/src/services/helpers/factoryFlow/__tests__/buildFactoryParams.spec.ts index 5a23ea7798..7c878719c2 100644 --- a/packages/dashboard-frontend/src/services/helpers/factoryFlow/__tests__/buildFactoryParams.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/factoryFlow/__tests__/buildFactoryParams.spec.ts @@ -11,6 +11,7 @@ */ import { + AI_PROVIDER_ATTR, buildFactoryParams, EDITOR_ATTR, EXISTING_WORKSPACE_NAME, @@ -20,6 +21,41 @@ import { } from '@/services/helpers/factoryFlow/buildFactoryParams'; describe('buildFactoryParams', () => { + describe('aiProviders', () => { + it('should return empty array when ai-provider is absent', () => { + const searchParams = new URLSearchParams({}); + expect(buildFactoryParams(searchParams).aiProviders).toEqual([]); + }); + + it('should return single-element array when one provider is present', () => { + const searchParams = new URLSearchParams({ + [AI_PROVIDER_ATTR]: 'google/gemini', + }); + expect(buildFactoryParams(searchParams).aiProviders).toEqual(['google/gemini']); + }); + + it('should return multiple providers from comma-separated value', () => { + const searchParams = new URLSearchParams({ + [AI_PROVIDER_ATTR]: 'google/gemini,anthropic/claude', + }); + expect(buildFactoryParams(searchParams).aiProviders).toEqual([ + 'google/gemini', + 'anthropic/claude', + ]); + }); + + it('should return empty array when ai-provider is an empty string', () => { + const searchParams = new URLSearchParams({ + [AI_PROVIDER_ATTR]: '', + }); + expect(buildFactoryParams(searchParams).aiProviders).toEqual([]); + }); + + it('should have AI_PROVIDER_ATTR constant equal to "ai-provider"', () => { + expect(AI_PROVIDER_ATTR).toBe('ai-provider'); + }); + }); + describe('getStorageType', () => { it('should return undefined', () => { const searchParams = new URLSearchParams({ diff --git a/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts b/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts index 8f71f475f5..2193bb6048 100644 --- a/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts +++ b/packages/dashboard-frontend/src/services/helpers/factoryFlow/buildFactoryParams.ts @@ -15,6 +15,7 @@ import { che } from '@/services/models'; export const NAME_ATTR = 'name'; export const DEV_WORKSPACE_ATTR = 'devWorkspace'; export const EDITOR_ATTR = 'che-editor'; +export const AI_PROVIDER_ATTR = 'ai-provider'; export const ERROR_CODE_ATTR = 'error_code'; export const FACTORY_URL_ATTR = 'url'; export const POLICIES_CREATE_ATTR = 'policies.create'; @@ -35,6 +36,7 @@ export const PROPAGATE_FACTORY_ATTRS = [ 'workspaceDeploymentLabels', DEV_WORKSPACE_ATTR, EDITOR_ATTR, + AI_PROVIDER_ATTR, FACTORY_URL_ATTR, POLICIES_CREATE_ATTR, STORAGE_TYPE_ATTR, @@ -71,6 +73,7 @@ export type FactoryParams = { debugWorkspaceStart: boolean; existing: string | undefined; name: string | undefined; + aiProviders: string[]; }; export type PoliciesCreate = 'perclick' | 'peruser'; @@ -98,6 +101,7 @@ export function buildFactoryParams(searchParams: URLSearchParams): FactoryParams debugWorkspaceStart: isDebugWorkspaceStart(searchParams) !== undefined, existing: getExistingWorkspaceName(searchParams), name: getName(searchParams), + aiProviders: getAiProviders(searchParams), }; } @@ -221,3 +225,14 @@ function getExistingWorkspaceName(searchParams: URLSearchParams): string | undef function getName(searchParams: URLSearchParams): string | undefined { return searchParams.get(NAME_ATTR) || undefined; } + +function getAiProviders(searchParams: URLSearchParams): string[] { + const value = searchParams.get(AI_PROVIDER_ATTR); + if (!value) { + return []; + } + return value + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); +} diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index b61977920d..40f7353439 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -130,6 +130,7 @@ export enum WorkspaceAction { } export enum UserPreferencesTab { + AI_PROVIDER_KEYS = 'AiProviderKeys', CONTAINER_REGISTRIES = 'ContainerRegistries', GIT_SERVICES = 'GitServices', GITCONFIG = 'Gitconfig', diff --git a/packages/dashboard-frontend/src/store/AiConfig/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/AiConfig/__tests__/actions.spec.ts new file mode 100644 index 0000000000..b2d352da84 --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/__tests__/actions.spec.ts @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2018-2025 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, helpers } from '@eclipse-che/common'; + +import { + deleteAiProviderKey, + fetchAiProviderKeyStatus, + fetchAiRegistry, + saveAiProviderKey, +} from '@/services/backend-client/aiConfigApi'; +import { createMockStore } from '@/store/__mocks__/mockActionsTestStore'; +import { + actionCreators, + aiConfigErrorAction, + aiConfigKeyStatusReceiveAction, + aiConfigRegistryReceiveAction, + aiConfigRequestAction, +} from '@/store/AiConfig/actions'; +import * as infrastructureNamespacesSelectors from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +jest.mock('@eclipse-che/common'); +jest.mock('@/services/backend-client/aiConfigApi'); +jest.mock('@/store/SanityCheck'); + +const mockNamespace = 'test-namespace'; +jest + .spyOn(infrastructureNamespacesSelectors, 'selectDefaultNamespace') + .mockReturnValue({ name: mockNamespace, attributes: { default: 'true', phase: 'Active' } }); + +const mockProviders: api.AiProviderDefinition[] = [ + { + id: 'google/gemini', + name: 'Gemini', + publisher: 'Google', + }, +]; + +const mockTools: api.AiToolDefinition[] = [ + { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/example/gemini-cli:next', + envVarName: 'GEMINI_API_KEY', + }, +]; + +function buildStore() { + return createMockStore({ + aiConfig: { + providers: mockProviders, + tools: mockTools, + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, + }, + }); +} + +describe('AiConfig, actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = buildStore(); + jest.clearAllMocks(); + }); + + describe('requestAiRegistry', () => { + it('should dispatch registry receive action', async () => { + const registry: api.IAiRegistry = { + providers: mockProviders, + tools: mockTools, + defaultAiProviders: ['google/gemini'], + }; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchAiRegistry as jest.Mock).mockResolvedValue(registry); + + await store.dispatch(actionCreators.requestAiRegistry()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigRegistryReceiveAction(registry)); + }); + + it('should dispatch error action on failure', async () => { + const errorMessage = 'Registry fetch failed'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchAiRegistry as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestAiRegistry())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigErrorAction(errorMessage)); + }); + }); + + describe('requestAiProviderKeyStatus', () => { + it('should dispatch key status receive action', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchAiProviderKeyStatus as jest.Mock).mockResolvedValue([]); + + await store.dispatch(actionCreators.requestAiProviderKeyStatus()); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigKeyStatusReceiveAction({ 'google/gemini': false })); + }); + + it('should dispatch error action on failed key status fetch', async () => { + const errorMessage = 'Network error'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (fetchAiProviderKeyStatus as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect(store.dispatch(actionCreators.requestAiProviderKeyStatus())).rejects.toThrow( + errorMessage, + ); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigErrorAction(errorMessage)); + }); + }); + + describe('saveAiProviderKey', () => { + it('should dispatch key status receive action on success', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (saveAiProviderKey as jest.Mock).mockResolvedValue(undefined); + (fetchAiProviderKeyStatus as jest.Mock).mockResolvedValue(['google-gemini']); + + await store.dispatch(actionCreators.saveAiProviderKey('google/gemini', 'test-api-key')); + + expect(saveAiProviderKey).toHaveBeenCalledWith( + mockNamespace, + 'google/gemini', + 'GEMINI_API_KEY', + 'test-api-key', + ); + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigKeyStatusReceiveAction({ 'google/gemini': true })); + }); + + it('should dispatch error action on failure', async () => { + const errorMessage = 'Save failed'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (saveAiProviderKey as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.saveAiProviderKey('google/gemini', 'key')), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigErrorAction(errorMessage)); + }); + }); + + describe('deleteAiProviderKey', () => { + it('should dispatch key status receive action on success', async () => { + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteAiProviderKey as jest.Mock).mockResolvedValue(undefined); + (fetchAiProviderKeyStatus as jest.Mock).mockResolvedValue([]); + + await store.dispatch(actionCreators.deleteAiProviderKey('google/gemini')); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigKeyStatusReceiveAction({ 'google/gemini': false })); + }); + + it('should dispatch error action on failure', async () => { + const errorMessage = 'Delete failed'; + + (verifyAuthorized as jest.Mock).mockResolvedValue(true); + (deleteAiProviderKey as jest.Mock).mockRejectedValue(new Error(errorMessage)); + (helpers.errors.getMessage as jest.Mock).mockReturnValue(errorMessage); + + await expect( + store.dispatch(actionCreators.deleteAiProviderKey('google/gemini')), + ).rejects.toThrow(errorMessage); + + const actions = store.getActions(); + expect(actions[0]).toEqual(aiConfigRequestAction()); + expect(actions[1]).toEqual(aiConfigErrorAction(errorMessage)); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/store/AiConfig/__tests__/reducer.spec.ts b/packages/dashboard-frontend/src/store/AiConfig/__tests__/reducer.spec.ts new file mode 100644 index 0000000000..33e0717cce --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/__tests__/reducer.spec.ts @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2018-2025 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'; +import { UnknownAction } from 'redux'; + +import { + aiConfigErrorAction, + aiConfigKeyStatusReceiveAction, + aiConfigRegistryReceiveAction, + aiConfigRequestAction, +} from '@/store/AiConfig/actions'; +import { AiConfigState, reducer, unloadedState } from '@/store/AiConfig/reducer'; + +describe('AiConfig, reducer', () => { + let initialState: AiConfigState; + + beforeEach(() => { + initialState = { ...unloadedState }; + }); + + it('should return the initial state', () => { + const unknownAction = { type: '@@INIT' } as UnknownAction; + expect(reducer(undefined, unknownAction)).toEqual(unloadedState); + }); + + it('should handle aiConfigRequestAction', () => { + initialState.error = 'previous error'; + const action = aiConfigRequestAction(); + const expectedState: AiConfigState = { + ...initialState, + isLoading: true, + error: undefined, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle aiConfigRegistryReceiveAction', () => { + initialState.isLoading = true; + + const registry: api.IAiRegistry = { + providers: [ + { + id: 'anthropic/claude', + name: 'Claude', + publisher: 'Anthropic', + }, + ], + tools: [ + { + providerId: 'anthropic/claude', + tag: 'latest', + name: 'claude-tool', + url: 'https://example.com/tool', + binary: 'claude', + pattern: 'init', + injectorImage: 'quay.io/example/claude-code:latest', + }, + ], + defaultAiProviders: ['anthropic/claude'], + }; + + const action = aiConfigRegistryReceiveAction(registry); + const expectedState: AiConfigState = { + ...initialState, + isLoading: false, + providers: registry.providers, + tools: registry.tools, + defaultAiProviders: registry.defaultAiProviders, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle aiConfigKeyStatusReceiveAction', () => { + initialState.isLoading = true; + + const providerKeyExists: Record = { + 'anthropic/claude': true, + 'openai/gpt': false, + }; + + const action = aiConfigKeyStatusReceiveAction(providerKeyExists); + const expectedState: AiConfigState = { + ...initialState, + isLoading: false, + providerKeyExists, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should handle aiConfigErrorAction', () => { + initialState.isLoading = true; + + const error = 'Something went wrong'; + const action = aiConfigErrorAction(error); + const expectedState: AiConfigState = { + ...initialState, + isLoading: false, + error, + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should return the current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as UnknownAction; + expect(reducer(initialState, unknownAction)).toEqual(initialState); + }); +}); diff --git a/packages/dashboard-frontend/src/store/AiConfig/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/AiConfig/__tests__/selectors.spec.ts new file mode 100644 index 0000000000..4d6e024bb6 --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/__tests__/selectors.spec.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +// Generated by AI Assistant + +import { RootState } from '@/store'; +import { AiConfigState, unloadedState } from '@/store/AiConfig/reducer'; +import { + selectAiConfigEnabled, + selectAiConfigError, + selectAiConfigIsLoading, + selectAiProviderKeyExists, + selectAiProviders, + selectAiTools, + selectDefaultAiProviders, +} from '@/store/AiConfig/selectors'; + +function buildState(overrides: Partial = {}): RootState { + return { + aiConfig: { ...unloadedState, ...overrides }, + } as RootState; +} + +describe('AiConfig, selectors', () => { + it('should select providers', () => { + const providers = [{ id: 'p1', name: 'Provider 1' }]; + const state = buildState({ + providers: providers as AiConfigState['providers'], + }); + expect(selectAiProviders(state)).toBe(providers); + }); + + it('should select tools', () => { + const tools = [{ providerId: 't1', name: 'Tool 1' }]; + const state = buildState({ + tools: tools as AiConfigState['tools'], + }); + expect(selectAiTools(state)).toBe(tools); + }); + + it('should select default AI providers', () => { + const defaults = ['openai', 'anthropic']; + const state = buildState({ defaultAiProviders: defaults }); + expect(selectDefaultAiProviders(state)).toBe(defaults); + }); + + it('should return true when providers and tools are non-empty', () => { + const state = buildState({ + providers: [{ id: 'p1' }] as AiConfigState['providers'], + tools: [{ providerId: 't1' }] as AiConfigState['tools'], + }); + expect(selectAiConfigEnabled(state)).toBe(true); + }); + + it('should return false when providers are empty', () => { + const state = buildState({ + providers: [], + tools: [{ providerId: 't1' }] as AiConfigState['tools'], + }); + expect(selectAiConfigEnabled(state)).toBe(false); + }); + + it('should return false when tools are empty', () => { + const state = buildState({ + providers: [{ id: 'p1' }] as AiConfigState['providers'], + tools: [], + }); + expect(selectAiConfigEnabled(state)).toBe(false); + }); + + it('should select provider key existence map', () => { + const keyMap = { anthropic: true, openai: false }; + const state = buildState({ providerKeyExists: keyMap }); + expect(selectAiProviderKeyExists(state)).toBe(keyMap); + }); + + it('should select loading state', () => { + const state = buildState({ isLoading: true }); + expect(selectAiConfigIsLoading(state)).toBe(true); + }); + + it('should select error', () => { + const state = buildState({ error: 'something failed' }); + expect(selectAiConfigError(state)).toBe('something failed'); + }); + + it('should return undefined error from initial state', () => { + const state = buildState(); + expect(selectAiConfigError(state)).toBeUndefined(); + }); +}); diff --git a/packages/dashboard-frontend/src/store/AiConfig/actions.ts b/packages/dashboard-frontend/src/store/AiConfig/actions.ts new file mode 100644 index 0000000000..b7e627034a --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/actions.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2018-2025 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, helpers } from '@eclipse-che/common'; +import { createAction } from '@reduxjs/toolkit'; + +import { + deleteAiProviderKey, + fetchAiProviderKeyStatus, + fetchAiRegistry, + saveAiProviderKey, +} from '@/services/backend-client/aiConfigApi'; +import { AppThunk } from '@/store'; +import { selectAiTools } from '@/store/AiConfig/selectors'; +import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; +import { verifyAuthorized } from '@/store/SanityCheck'; + +function buildKeyExistsMap( + tools: api.AiToolDefinition[], + sanitizedIdsWithKey: string[], +): Record { + const keyExists: Record = {}; + for (const tool of tools) { + keyExists[tool.providerId] = sanitizedIdsWithKey.includes( + tool.providerId.replace(/[^a-zA-Z0-9._-]/g, '-'), + ); + } + return keyExists; +} + +export const aiConfigRequestAction = createAction('aiConfig/request'); + +export const aiConfigRegistryReceiveAction = createAction( + 'aiConfig/registryReceive', +); + +export const aiConfigKeyStatusReceiveAction = createAction>( + 'aiConfig/keyStatusReceive', +); + +export const aiConfigErrorAction = createAction('aiConfig/error'); + +async function refreshKeyStatus( + dispatch: Parameters[0], + getState: Parameters[1], +): Promise { + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + const tools = selectAiTools(state); + if (namespace && tools.length > 0) { + const sanitizedIds = await fetchAiProviderKeyStatus(namespace); + dispatch(aiConfigKeyStatusReceiveAction(buildKeyExistsMap(tools, sanitizedIds))); + } else { + dispatch(aiConfigKeyStatusReceiveAction({})); + } +} + +export const actionCreators = { + requestAiRegistry: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(aiConfigRequestAction()); + + const registry = await fetchAiRegistry(); + dispatch(aiConfigRegistryReceiveAction(registry)); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(aiConfigErrorAction(errorMessage)); + throw e; + } + }, + + requestAiProviderKeyStatus: (): AppThunk => async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(aiConfigRequestAction()); + + await refreshKeyStatus(dispatch, getState); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(aiConfigErrorAction(errorMessage)); + throw e; + } + }, + + saveAiProviderKey: + (toolId: string, apiKey: string): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(aiConfigRequestAction()); + + const state = getState(); + const namespace = selectDefaultNamespace(state).name; + const tools = selectAiTools(state); + const tool = tools.find(t => t.providerId === toolId); + const envVarName = tool?.envVarName ?? ''; + await saveAiProviderKey(namespace, toolId, envVarName, apiKey); + await refreshKeyStatus(dispatch, getState); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(aiConfigErrorAction(errorMessage)); + throw e; + } + }, + + deleteAiProviderKey: + (toolId: string): AppThunk => + async (dispatch, getState) => { + try { + await verifyAuthorized(dispatch, getState); + + dispatch(aiConfigRequestAction()); + + const namespace = selectDefaultNamespace(getState()).name; + await deleteAiProviderKey(namespace, toolId); + await refreshKeyStatus(dispatch, getState); + } catch (e) { + const errorMessage = helpers.errors.getMessage(e); + dispatch(aiConfigErrorAction(errorMessage)); + throw e; + } + }, +}; diff --git a/packages/dashboard-frontend/src/store/AiConfig/index.ts b/packages/dashboard-frontend/src/store/AiConfig/index.ts new file mode 100644 index 0000000000..41b8ee6937 --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/index.ts @@ -0,0 +1,21 @@ +/* c8 ignore start */ + +/* + * Copyright (c) 2018-2025 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 + */ + +export { actionCreators as aiConfigActionCreators } from '@/store/AiConfig/actions'; +export { + reducer as aiConfigReducer, + AiConfigState, + unloadedState as aiConfigUnloadedState, +} from '@/store/AiConfig/reducer'; +export * from '@/store/AiConfig/selectors'; diff --git a/packages/dashboard-frontend/src/store/AiConfig/reducer.ts b/packages/dashboard-frontend/src/store/AiConfig/reducer.ts new file mode 100644 index 0000000000..045627b159 --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/reducer.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018-2025 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'; +import { createReducer } from '@reduxjs/toolkit'; + +import { + aiConfigErrorAction, + aiConfigKeyStatusReceiveAction, + aiConfigRegistryReceiveAction, + aiConfigRequestAction, +} from '@/store/AiConfig/actions'; + +export type AiConfigState = { + providers: api.AiProviderDefinition[]; + tools: api.AiToolDefinition[]; + defaultAiProviders: string[]; + providerKeyExists: Record; + isLoading: boolean; + error: string | undefined; +}; + +export const unloadedState: AiConfigState = { + providers: [], + tools: [], + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, +}; + +export const reducer = createReducer(unloadedState, builder => + builder + .addCase(aiConfigRequestAction, state => { + state.isLoading = true; + state.error = undefined; + }) + .addCase(aiConfigRegistryReceiveAction, (state, action) => { + state.isLoading = false; + state.providers = action.payload.providers; + state.tools = action.payload.tools; + state.defaultAiProviders = action.payload.defaultAiProviders; + }) + .addCase(aiConfigKeyStatusReceiveAction, (state, action) => { + state.isLoading = false; + state.providerKeyExists = action.payload; + }) + .addCase(aiConfigErrorAction, (state, action) => { + state.isLoading = false; + state.error = action.payload; + }) + .addDefaultCase(state => state), +); diff --git a/packages/dashboard-frontend/src/store/AiConfig/selectors.ts b/packages/dashboard-frontend/src/store/AiConfig/selectors.ts new file mode 100644 index 0000000000..38bab11aef --- /dev/null +++ b/packages/dashboard-frontend/src/store/AiConfig/selectors.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018-2025 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 { createSelector } from '@reduxjs/toolkit'; + +import { RootState } from '@/store'; + +const selectState = (state: RootState) => state.aiConfig; + +export const selectAiProviders = createSelector(selectState, state => state.providers); + +export const selectAiTools = createSelector(selectState, state => state.tools); + +export const selectDefaultAiProviders = createSelector( + selectState, + state => state.defaultAiProviders, +); + +export const selectAiConfigEnabled = createSelector( + selectState, + state => state.providers.length > 0 && state.tools.length > 0, +); + +export const selectAiProviderKeyExists = createSelector( + selectState, + state => state.providerKeyExists, +); + +export const selectAiConfigIsLoading = createSelector(selectState, state => state.isLoading); + +export const selectAiConfigError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/Backups/__tests__/actions.spec.ts b/packages/dashboard-frontend/src/store/Backups/__tests__/actions.spec.ts index 159763975f..011b62712a 100644 --- a/packages/dashboard-frontend/src/store/Backups/__tests__/actions.spec.ts +++ b/packages/dashboard-frontend/src/store/Backups/__tests__/actions.spec.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import { BackupStatus } from '@eclipse-che/common'; import { configureStore } from '@reduxjs/toolkit'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restoreWorkspaceFromBackup.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restoreWorkspaceFromBackup.spec.ts index 892484e3ed..ba31c518b0 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restoreWorkspaceFromBackup.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/restoreWorkspaceFromBackup.spec.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import * as DwApi from '@/services/backend-client/devWorkspaceApi'; import * as devworkspaceResourcesApi from '@/services/backend-client/devworkspaceResourcesApi'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts index 39e30c4b6d..e7a2b73ba8 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/startWorkspace.spec.ts @@ -47,6 +47,18 @@ jest.mock('@/store/DevWorkspacesCluster'); jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'); jest.mock('@eclipse-che/common'); +const mockPatchWorkspace = jest.fn(); +jest.mock('@/services/backend-client/devWorkspaceApi', () => ({ + patchWorkspace: (...args: unknown[]) => mockPatchWorkspace(...args), +})); + +const mockSanitizeStaleAiTools = jest.fn().mockReturnValue(null); +const mockUpdateOutdatedAiTools = jest.fn().mockReturnValue(null); +jest.mock('@/services/helpers/aiTools', () => ({ + sanitizeStaleAiTools: (...args: unknown[]) => mockSanitizeStaleAiTools(...args), + updateOutdatedAiTools: (...args: unknown[]) => mockUpdateOutdatedAiTools(...args), +})); + const mockNamespace = 'test-namespace'; const mockName = 'test-workspace'; const mockWorkspace = { @@ -83,6 +95,14 @@ describe('devWorkspaces, actions', () => { dwServerConfig: { config: {}, }, + aiConfig: { + providers: [], + tools: [], + defaultAiProviders: [], + providerKeyExists: {}, + isLoading: false, + error: undefined, + }, devWorkspaces: { isLoading: false, resourceVersion: '', @@ -90,7 +110,7 @@ describe('devWorkspaces, actions', () => { startedWorkspaces: {}, warnings: {}, }, - } as Partial as RootState); + } as unknown as RootState); (getDevWorkspaceClient as jest.Mock).mockReturnValue({ changeWorkspaceStatus: mockChangeWorkspaceStatus, @@ -144,7 +164,7 @@ describe('devWorkspaces, actions', () => { startedWorkspaces: {}, warnings: {}, }, - } as Partial as RootState); + } as unknown as RootState); await storeWithStartedWorkspace.dispatch(startWorkspace(mockWorkspace)); @@ -257,5 +277,51 @@ describe('devWorkspaces, actions', () => { ), ); }); + + it('should patch workspace with template and annotations when AI tools are sanitized', async () => { + const patchedWorkspace = { + ...mockWorkspace, + metadata: { + ...mockWorkspace.metadata, + annotations: { 'che.eclipse.org/pending-ai-cleanup': '' }, + }, + spec: { + ...mockWorkspace.spec, + template: { components: [] }, + }, + }; + mockSanitizeStaleAiTools.mockReturnValueOnce(patchedWorkspace); + mockPatchWorkspace.mockResolvedValueOnce({ devWorkspace: mockWorkspace }); + + await store.dispatch(startWorkspace(mockWorkspace)); + + expect(mockPatchWorkspace).toHaveBeenCalledWith(mockNamespace, mockName, [ + { op: 'replace', path: '/spec/template', value: patchedWorkspace.spec.template }, + { + op: 'replace', + path: '/metadata/annotations', + value: patchedWorkspace.metadata.annotations, + }, + ]); + }); + + it('should patch workspace with template only when annotations are absent', async () => { + const patchedWorkspace = { + ...mockWorkspace, + metadata: { ...mockWorkspace.metadata, annotations: undefined }, + spec: { + ...mockWorkspace.spec, + template: { components: [] }, + }, + }; + mockSanitizeStaleAiTools.mockReturnValueOnce(patchedWorkspace); + mockPatchWorkspace.mockResolvedValueOnce({ devWorkspace: mockWorkspace }); + + await store.dispatch(startWorkspace(mockWorkspace)); + + expect(mockPatchWorkspace).toHaveBeenCalledWith(mockNamespace, mockName, [ + { op: 'replace', path: '/spec/template', value: patchedWorkspace.spec.template }, + ]); + }); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts index 790faf4b9b..3cbe2fa635 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/createWorkspaceFromResources.ts @@ -13,8 +13,10 @@ import common, { ApplicationId } from '@eclipse-che/common'; import devfileApi from '@/services/devfileApi'; +import { addAiToolToWorkspace } from '@/services/helpers/aiTools'; import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { AppThunk } from '@/store'; +import { selectAiTools } from '@/store/AiConfig/selectors'; import { selectApplications } from '@/store/ClusterInfo'; import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces'; import { verifyAuthorized } from '@/store/SanityCheck'; @@ -53,6 +55,16 @@ export const createWorkspaceFromResources = dispatch(devWorkspacesRequestAction()); + // Inject AI tools into the DevWorkspace if selected + if (params.aiProviders && params.aiProviders.length > 0) { + const aiTools = selectAiTools(state); + if (aiTools.length > 0) { + for (const providerId of params.aiProviders) { + devWorkspace = addAiToolToWorkspace(devWorkspace, providerId, aiTools); + } + } + } + /* create a new DevWorkspace */ const createResp = await getDevWorkspaceClient().createDevWorkspace( defaultNamespace, diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restoreWorkspaceFromBackup.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restoreWorkspaceFromBackup.ts index 7036cce621..d9dec464c6 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restoreWorkspaceFromBackup.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/restoreWorkspaceFromBackup.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by AI Assistant import common, { ApplicationId } from '@eclipse-che/common'; import { DEVWORKSPACE_RESTORE_ATTRIBUTES } from '@eclipse-che/common'; diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts index 7a7438ef11..4b97b7e1ec 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/startWorkspace.ts @@ -12,10 +12,13 @@ import common from '@eclipse-che/common'; +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; import devfileApi from '@/services/devfileApi'; import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { sanitizeStaleAiTools, updateOutdatedAiTools } from '@/services/helpers/aiTools'; import { isOAuthResponse, OAuthService } from '@/services/oauth'; import { AppThunk } from '@/store'; +import { selectAiTools } from '@/store/AiConfig/selectors'; import { checkRunningDevWorkspacesClusterLimitExceeded, devWorkspacesClusterActionCreators, @@ -87,6 +90,32 @@ export const startWorkspace = workspace = await getDevWorkspaceClient().manageDebugMode(workspace, debugWorkspace); + // Sanitize stale AI tools and update outdated ones in a single PATCH. + const aiTools = selectAiTools(getState()); + const aiPatched = sanitizeStaleAiTools(workspace, aiTools); + const updateSource = aiPatched ?? workspace; + const aiUpdated = updateOutdatedAiTools(updateSource, aiTools); + const aiResult = aiUpdated ?? aiPatched; + if (aiResult) { + const patchOps: { op: string; path: string; value: unknown }[] = [ + { op: 'replace', path: '/spec/template', value: aiResult.spec.template }, + ]; + // Persist annotation changes (e.g. clearing PENDING_CLEANUP_ANNOTATION) + if (aiResult.metadata?.annotations) { + patchOps.push({ + op: 'replace', + path: '/metadata/annotations', + value: aiResult.metadata.annotations, + }); + } + const { devWorkspace } = await DwApi.patchWorkspace( + workspace.metadata.namespace, + workspace.metadata.name, + patchOps, + ); + workspace = devWorkspace; + } + const editorName = getEditorName(workspace); const lifeTimeMs = getLifeTimeMs(workspace); if (editorName && lifeTimeMs > 30000) { diff --git a/packages/dashboard-frontend/src/store/rootReducer.ts b/packages/dashboard-frontend/src/store/rootReducer.ts index 887baa5f29..fb2d376374 100644 --- a/packages/dashboard-frontend/src/store/rootReducer.ts +++ b/packages/dashboard-frontend/src/store/rootReducer.ts @@ -10,6 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ +import { aiConfigReducer } from '@/store/AiConfig'; import { backupsReducer } from '@/store/Backups'; import { bannerAlertReducer } from '@/store/BannerAlert'; import { brandingReducer } from '@/store/Branding'; @@ -38,6 +39,7 @@ import { devWorkspacesReducer } from '@/store/Workspaces/devWorkspaces'; import { workspacePreferencesReducer } from '@/store/Workspaces/Preferences'; export const rootReducer = { + aiConfig: aiConfigReducer, backups: backupsReducer, bannerAlert: bannerAlertReducer, branding: brandingReducer, diff --git a/packages/dashboard-frontend/src/typings/svg.module.d.ts b/packages/dashboard-frontend/src/typings/svg.module.d.ts new file mode 100644 index 0000000000..b4477bb235 --- /dev/null +++ b/packages/dashboard-frontend/src/typings/svg.module.d.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2018-2025 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 + */ + +declare module '*.svg' { + const content: string; + export default content; +}