From 14bc569e0a221df2b13d41dd690ac8e1dcb91e11 Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Tue, 25 Aug 2026 13:18:23 +0300 Subject: [PATCH 1/2] Implement Agents and remove Providers --- .../replace-providers-with-agents.md | 14 + .../clients/{ProvidersApi.ts => AgentsApi.ts} | 22 +- .../src/clients/AgentsClient.test.ts | 149 +++++++ .../dcm-common/src/clients/AgentsClient.ts | 72 ++++ .../src/clients/ProvidersClient.test.ts | 124 ------ .../dcm-common/src/clients/ProvidersClient.ts | 63 --- .../plugins/dcm-common/src/clients/index.ts | 4 +- .../plugins/dcm-common/src/types/agents.ts | 80 ++++ .../dcm/plugins/dcm-common/src/types/index.ts | 2 +- .../plugins/dcm-common/src/types/providers.ts | 70 ---- workspaces/dcm/plugins/dcm/src/apis.ts | 15 +- workspaces/dcm/plugins/dcm/src/index.ts | 2 +- .../pages/agents/AgentsTabContent.test.tsx | 208 ++++++++++ .../dcm/src/pages/agents/AgentsTabContent.tsx | 223 +++++++++++ .../src/pages/agents/agentFormTypes.test.ts | 162 ++++++++ .../dcm/src/pages/agents/agentFormTypes.ts | 131 ++++++ .../agents/components/AgentFormFields.tsx | 184 +++++++++ .../components/AgentHealthStatus.tsx} | 43 +- .../pages/agents/components/CopyButton.tsx | 59 +++ .../src/pages/data-center/DataCenterPage.tsx | 6 +- .../providers/ProvidersTabContent.test.tsx | 293 -------------- .../pages/providers/ProvidersTabContent.tsx | 377 ------------------ .../providers/components/CopyButton.test.tsx | 91 ----- .../pages/providers/components/CopyButton.tsx | 84 ---- .../components/ProviderFormFields.test.tsx | 177 -------- .../components/ProviderFormFields.tsx | 182 --------- .../pages/providers/providerFormTypes.test.ts | 130 ------ .../src/pages/providers/providerFormTypes.ts | 148 ------- workspaces/dcm/plugins/dcm/src/plugin.ts | 12 +- workspaces/dcm/plugins/dcm/src/routes.ts | 6 +- .../dcm/plugins/dcm/src/translations/de.ts | 92 ++--- .../dcm/plugins/dcm/src/translations/es.ts | 95 ++--- .../dcm/plugins/dcm/src/translations/fr.ts | 98 ++--- .../dcm/plugins/dcm/src/translations/it.ts | 94 ++--- .../dcm/plugins/dcm/src/translations/ja.ts | 116 +++--- .../dcm/plugins/dcm/src/translations/ref.ts | 72 ++-- 36 files changed, 1574 insertions(+), 2126 deletions(-) create mode 100644 workspaces/dcm/.changeset/replace-providers-with-agents.md rename workspaces/dcm/plugins/dcm-common/src/clients/{ProvidersApi.ts => AgentsApi.ts} (58%) create mode 100644 workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts create mode 100644 workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts delete mode 100644 workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts delete mode 100644 workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts create mode 100644 workspaces/dcm/plugins/dcm-common/src/types/agents.ts delete mode 100644 workspaces/dcm/plugins/dcm-common/src/types/providers.ts create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx rename workspaces/dcm/plugins/dcm/src/pages/{providers/components/ProviderStatus.tsx => agents/components/AgentHealthStatus.tsx} (57%) create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.test.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.tsx delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.test.ts delete mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.ts diff --git a/workspaces/dcm/.changeset/replace-providers-with-agents.md b/workspaces/dcm/.changeset/replace-providers-with-agents.md new file mode 100644 index 00000000000..53b8971b983 --- /dev/null +++ b/workspaces/dcm/.changeset/replace-providers-with-agents.md @@ -0,0 +1,14 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm': minor +'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +--- + +Replace Providers tab with Agents tab. + +The Providers API has been removed by the DCM API team and is no longer available. +All provider-related UI, types, and client code have been removed. + +A new Agents tab has been added as the default landing tab, backed by the +Agent API (v1alpha1). Agents register with the control plane and send periodic +heartbeats. The UI supports listing agents with health-status filtering and +registering new agents. diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts similarity index 58% rename from workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts rename to workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts index 21098fd466d..6979a329136 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts @@ -15,17 +15,23 @@ */ import type { PaginationParams } from '../types/common'; -import type { Provider, ProviderList } from '../types/providers'; +import type { + Agent, + AgentList, + AgentRegistrationRequest, + HeartbeatRequest, +} from '../types/agents'; /** - * Interface for the DCM Providers API client. + * Interface for the DCM Agents API client. * * @public */ -export interface ProvidersApi { - listProviders(params?: PaginationParams): Promise; - getProvider(providerId: string): Promise; - createProvider(provider: Provider): Promise; - applyProvider(providerId: string, provider: Provider): Promise; - deleteProvider(providerId: string): Promise; +export interface AgentsApi { + listAgents( + params?: PaginationParams & { health_status?: string }, + ): Promise; + getAgent(agentId: string): Promise; + createAgent(agent: AgentRegistrationRequest): Promise; + agentHeartbeat(agentId: string, heartbeat: HeartbeatRequest): Promise; } diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts new file mode 100644 index 00000000000..c6daab9b862 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; +import { AgentsClient } from './AgentsClient'; +import type { + Agent, + AgentRegistrationRequest, + HeartbeatRequest, +} from '../types/agents'; + +const BASE_URL = 'http://localhost/api/dcm'; + +const MOCK_REGISTRATION: AgentRegistrationRequest = { + name: 'env-agent-west-1', + environment: 'production', + service_types: ['vm', 'container'], + cost: 'medium', + topic_name: 'dcm.agent.env-agent-west-1', +}; + +const MOCK_AGENT: Agent = { + ...MOCK_REGISTRATION, + agent_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + health_status: 'ready', +}; + +function makeClient(fetchFn: jest.Mock) { + const discoveryApi: DiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue(BASE_URL), + }; + const fetchApi: FetchApi = { fetch: fetchFn }; + return new AgentsClient({ discoveryApi, fetchApi }); +} + +function okJson(data: unknown): Response { + return { + status: 200, + ok: true, + json: async () => data, + } as unknown as Response; +} + +describe('AgentsClient', () => { + it('listAgents calls GET /agents', async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(okJson({ agents: [MOCK_AGENT] })); + const client = makeClient(fetchFn); + + await client.listAgents(); + + expect(fetchFn).toHaveBeenCalledWith( + `${BASE_URL}/proxy/agents`, + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it('listAgents appends max_page_size and page_token query params', async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(okJson({ agents: [MOCK_AGENT] })); + const client = makeClient(fetchFn); + + await client.listAgents({ max_page_size: 10, page_token: 'tok-1' }); + + const [url] = fetchFn.mock.calls[0]; + expect(url).toContain('max_page_size=10'); + expect(url).toContain('page_token=tok-1'); + }); + + it('listAgents appends health_status query param when provided', async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(okJson({ agents: [MOCK_AGENT] })); + const client = makeClient(fetchFn); + + await client.listAgents({ health_status: 'ready' }); + + const [url] = fetchFn.mock.calls[0]; + expect(url).toContain('health_status=ready'); + }); + + it('getAgent calls GET /agents/{id}', async () => { + const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_AGENT)); + const client = makeClient(fetchFn); + + await client.getAgent('my-agent-id'); + + expect(fetchFn).toHaveBeenCalledWith( + `${BASE_URL}/proxy/agents/my-agent-id`, + expect.any(Object), + ); + }); + + it('createAgent calls POST /agents with JSON body', async () => { + const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_AGENT)); + const client = makeClient(fetchFn); + + await client.createAgent(MOCK_REGISTRATION); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/proxy/agents`); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual(MOCK_REGISTRATION); + }); + + it('createAgent returns 201 response body correctly', async () => { + const fetchFn = jest.fn().mockResolvedValue({ + status: 201, + ok: true, + json: async () => MOCK_AGENT, + } as unknown as Response); + const client = makeClient(fetchFn); + + const result = await client.createAgent(MOCK_REGISTRATION); + + expect(result).toEqual(MOCK_AGENT); + }); + + it('agentHeartbeat calls PUT /agents/{id}/heartbeat with JSON body', async () => { + const heartbeat: HeartbeatRequest = { + consumer_lag: 0, + timestamp: '2026-08-25T12:00:00Z', + }; + const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_AGENT)); + const client = makeClient(fetchFn); + + await client.agentHeartbeat('my-agent-id', heartbeat); + + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe(`${BASE_URL}/proxy/agents/my-agent-id/heartbeat`); + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body)).toEqual(heartbeat); + }); +}); diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts new file mode 100644 index 00000000000..f0b514084ed --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts @@ -0,0 +1,72 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PaginationParams } from '../types/common'; +import type { + Agent, + AgentList, + AgentRegistrationRequest, + HeartbeatRequest, +} from '../types/agents'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; +import type { AgentsApi } from './AgentsApi'; +import { DcmBaseClient } from './DcmBaseClient'; + +/** + * Calls the DCM Agents API through the dcm-backend secure proxy. + * + * All requests are sent to `/api/dcm/proxy/` where the backend + * strips the `/proxy` prefix and forwards to: + * `{dcm.apiUrl}/api/v1alpha1/` + * + * @public + */ +export class AgentsClient extends DcmBaseClient implements AgentsApi { + protected readonly serviceName = 'Agents'; + + async listAgents( + params: PaginationParams & { health_status?: string } = {}, + ): Promise { + const { health_status, ...pagination } = params; + let query = buildPaginationQuery(pagination); + if (health_status) { + const sep = query ? '&' : '?'; + query += `${sep}health_status=${encodeURIComponent(health_status)}`; + } + return this.fetch(`agents${query}`); + } + + async getAgent(agentId: string): Promise { + return this.fetch(`agents/${agentId}`); + } + + async createAgent(agent: AgentRegistrationRequest): Promise { + return this.fetch('agents', { + method: 'POST', + body: JSON.stringify(agent), + }); + } + + async agentHeartbeat( + agentId: string, + heartbeat: HeartbeatRequest, + ): Promise { + return this.fetch(`agents/${agentId}/heartbeat`, { + method: 'PUT', + body: JSON.stringify(heartbeat), + }); + } +} diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts deleted file mode 100644 index 4431698d835..00000000000 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import { ProvidersClient } from './ProvidersClient'; -import type { Provider } from '../types/providers'; - -const BASE_URL = 'http://localhost/api/dcm'; - -const MOCK_PROVIDER: Provider = { - name: 'test-provider', - display_name: 'Test Provider', - endpoint: 'https://provider.example.com', - service_type: 'openshift', - schema_version: 'v1alpha1', -}; - -function makeClient(fetchFn: jest.Mock) { - const discoveryApi: DiscoveryApi = { - getBaseUrl: jest.fn().mockResolvedValue(BASE_URL), - }; - const fetchApi: FetchApi = { fetch: fetchFn }; - return new ProvidersClient({ discoveryApi, fetchApi }); -} - -function okJson(data: unknown): Response { - return { - status: 200, - ok: true, - json: async () => data, - } as unknown as Response; -} - -describe('ProvidersClient', () => { - it('listProviders calls GET /providers', async () => { - const fetchFn = jest - .fn() - .mockResolvedValue(okJson({ providers: [MOCK_PROVIDER] })); - const client = makeClient(fetchFn); - - await client.listProviders(); - - expect(fetchFn).toHaveBeenCalledWith( - `${BASE_URL}/proxy/providers`, - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - it('listProviders appends max_page_size and page_token query params', async () => { - const fetchFn = jest - .fn() - .mockResolvedValue(okJson({ providers: [MOCK_PROVIDER] })); - const client = makeClient(fetchFn); - - await client.listProviders({ max_page_size: 10, page_token: 'tok-1' }); - - const [url] = fetchFn.mock.calls[0]; - expect(url).toContain('max_page_size=10'); - expect(url).toContain('page_token=tok-1'); - }); - - it('getProvider calls GET /providers/{id}', async () => { - const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_PROVIDER)); - const client = makeClient(fetchFn); - - await client.getProvider('my-id'); - - expect(fetchFn).toHaveBeenCalledWith( - `${BASE_URL}/proxy/providers/my-id`, - expect.any(Object), - ); - }); - - it('createProvider calls POST /providers with JSON body', async () => { - const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_PROVIDER)); - const client = makeClient(fetchFn); - - await client.createProvider(MOCK_PROVIDER); - - const [url, init] = fetchFn.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/proxy/providers`); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body)).toEqual(MOCK_PROVIDER); - }); - - it('applyProvider calls PUT /providers/{id} with JSON body', async () => { - const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_PROVIDER)); - const client = makeClient(fetchFn); - - await client.applyProvider('my-id', MOCK_PROVIDER); - - const [url, init] = fetchFn.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/proxy/providers/my-id`); - expect(init.method).toBe('PUT'); - expect(JSON.parse(init.body)).toEqual(MOCK_PROVIDER); - }); - - it('deleteProvider calls DELETE /providers/{id} and returns undefined', async () => { - const fetchFn = jest - .fn() - .mockResolvedValue({ status: 204, ok: true } as Response); - const client = makeClient(fetchFn); - - const result = await client.deleteProvider('my-id'); - - expect(result).toBeUndefined(); - const [url, init] = fetchFn.mock.calls[0]; - expect(url).toBe(`${BASE_URL}/proxy/providers/my-id`); - expect(init.method).toBe('DELETE'); - }); -}); diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts deleted file mode 100644 index db626323920..00000000000 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { PaginationParams } from '../types/common'; -import type { Provider, ProviderList } from '../types/providers'; -import { buildPaginationQuery } from '../utils/buildPaginationQuery'; -import type { ProvidersApi } from './ProvidersApi'; -import { DcmBaseClient } from './DcmBaseClient'; - -/** - * Calls the DCM Providers API through the dcm-backend secure proxy. - * - * All requests are sent to `/api/dcm/proxy/` where the backend - * strips the `/proxy` prefix and forwards to: - * `{dcm.apiUrl}/api/v1alpha1/` - * - * @public - */ -export class ProvidersClient extends DcmBaseClient implements ProvidersApi { - protected readonly serviceName = 'Providers'; - - async listProviders(params: PaginationParams = {}): Promise { - return this.fetch(`providers${buildPaginationQuery(params)}`); - } - - async getProvider(providerId: string): Promise { - return this.fetch(`providers/${providerId}`); - } - - async createProvider(provider: Provider): Promise { - return this.fetch('providers', { - method: 'POST', - body: JSON.stringify(provider), - }); - } - - async applyProvider( - providerId: string, - provider: Provider, - ): Promise { - return this.fetch(`providers/${providerId}`, { - method: 'PUT', - body: JSON.stringify(provider), - }); - } - - async deleteProvider(providerId: string): Promise { - return this.fetch(`providers/${providerId}`, { method: 'DELETE' }); - } -} diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/index.ts b/workspaces/dcm/plugins/dcm-common/src/clients/index.ts index 6165140e508..954a659628d 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/index.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/index.ts @@ -16,11 +16,11 @@ export type { CatalogApi } from './CatalogApi'; export type { PolicyManagerApi } from './PolicyManagerApi'; -export type { ProvidersApi } from './ProvidersApi'; +export type { AgentsApi } from './AgentsApi'; export type { ResourcesApi } from './ResourcesApi'; export { DcmBaseClient } from './DcmBaseClient'; export { CatalogClient } from './CatalogClient'; export { PolicyManagerClient } from './PolicyManagerClient'; -export { ProvidersClient } from './ProvidersClient'; +export { AgentsClient } from './AgentsClient'; export { ResourcesClient } from './ResourcesClient'; diff --git a/workspaces/dcm/plugins/dcm-common/src/types/agents.ts b/workspaces/dcm/plugins/dcm-common/src/types/agents.ts new file mode 100644 index 00000000000..e012204b4f6 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/types/agents.ts @@ -0,0 +1,80 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * DCM Agents API types — derived from Agent API OpenAPI spec (v1alpha1). + * + * @public + */ + +/** Relative cost weight for placement decisions. */ +export type AgentCost = + | 'low' + | 'medium-low' + | 'medium' + | 'medium-high' + | 'high'; + +/** Current health status of a registered agent (readOnly). */ +export type AgentHealthStatus = 'ready' | 'congested' | 'unavailable'; + +/** A registered environment agent. */ +export interface Agent { + /** Server-generated unique identifier (readOnly). */ + agent_id?: string; + /** Unique name of the agent. */ + name: string; + /** Environment label for the agent. */ + environment: string; + /** List of service types this agent can provide. */ + service_types: string[]; + /** Relative cost weight for placement decisions. */ + cost: AgentCost; + /** NATS topic name for this agent (must start with dcm.agent.). */ + topic_name: string; + /** Current health status (readOnly). */ + health_status?: AgentHealthStatus; + /** Timestamp of last heartbeat received (readOnly). */ + last_heartbeat?: string; + /** Timestamp when the agent was first registered (readOnly). */ + create_time?: string; + /** Timestamp when the agent was last updated (readOnly). */ + update_time?: string; +} + +/** Request body for registering or re-registering an agent. */ +export interface AgentRegistrationRequest { + name: string; + environment: string; + service_types: string[]; + cost: AgentCost; + /** NATS topic name — must start with dcm.agent. */ + topic_name: string; +} + +/** Paginated list of {@link Agent} resources. */ +export interface AgentList { + agents?: Agent[]; + next_page_token?: string; +} + +/** Request body for an agent heartbeat. */ +export interface HeartbeatRequest { + /** Number of unprocessed messages in the agent's NATS consumer. */ + consumer_lag: number; + /** Timestamp of this heartbeat (used for monotonicity check). */ + timestamp: string; +} diff --git a/workspaces/dcm/plugins/dcm-common/src/types/index.ts b/workspaces/dcm/plugins/dcm-common/src/types/index.ts index b70f1518af3..fff4adda337 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/index.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/index.ts @@ -17,5 +17,5 @@ export * from './catalog'; export * from './common'; export * from './policy-manager'; -export * from './providers'; +export * from './agents'; export * from './resources'; diff --git a/workspaces/dcm/plugins/dcm-common/src/types/providers.ts b/workspaces/dcm/plugins/dcm-common/src/types/providers.ts deleted file mode 100644 index 68d46db1026..00000000000 --- a/workspaces/dcm/plugins/dcm-common/src/types/providers.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * DCM Providers API types — derived from providers OpenAPI spec. - * - * @public - */ - -/** Capacity metrics for a provider's infrastructure. */ -export interface ResourceCapacity { - total_cpu?: number; - total_memory?: string; - total_storage?: string; - total_node?: number; -} - -/** Provider metadata — includes optional open-ended extra fields. */ -export interface ProviderMetadata { - region_code?: string; - zone?: string; - status?: string; - resources?: ResourceCapacity; - [key: string]: unknown; -} - -/** Lifecycle status of a registered provider (readOnly). */ -export type ProviderStatus = 'registered' | 'updated'; - -/** A service provider registered with DCM. */ -export interface Provider { - /** Unique identifier (readOnly). */ - id?: string; - /** Resource path (readOnly). */ - path?: string; - name: string; - display_name?: string; - /** Provider API endpoint URI. */ - endpoint: string; - service_type: string; - /** API schema version supported by this provider (e.g. `v1alpha1`). */ - schema_version: string; - /** List of supported operation names. */ - operations?: string[]; - metadata?: ProviderMetadata; - /** Lifecycle status set by the server (readOnly). */ - status?: ProviderStatus; - health_status?: string; - create_time?: string; - update_time?: string; -} - -/** Paginated list of {@link Provider} resources. */ -export interface ProviderList { - providers?: Provider[]; - next_page_token?: string; -} diff --git a/workspaces/dcm/plugins/dcm/src/apis.ts b/workspaces/dcm/plugins/dcm/src/apis.ts index 0b94d56217b..8c0eb5217e9 100644 --- a/workspaces/dcm/plugins/dcm/src/apis.ts +++ b/workspaces/dcm/plugins/dcm/src/apis.ts @@ -17,9 +17,9 @@ import { createApiRef } from '@backstage/core-plugin-api'; import type { ApiRef } from '@backstage/core-plugin-api'; import type { + AgentsApi, CatalogApi, PolicyManagerApi, - ProvidersApi, ResourcesApi, } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; @@ -48,17 +48,16 @@ export const policyManagerApiRef: ApiRef = }); /** - * Backstage API ref for the DCM Providers service. + * Backstage API ref for the DCM Agents service. * - * Provides CRUD operations for Providers via the dcm-backend secure proxy. + * Provides operations for listing and registering environment agents via + * the dcm-backend secure proxy. * * @public */ -export const providersApiRef: ApiRef = createApiRef( - { - id: 'plugin.dcm.providers', - }, -); +export const agentsApiRef: ApiRef = createApiRef({ + id: 'plugin.dcm.agents', +}); /** * Backstage API ref for the DCM Resources service. diff --git a/workspaces/dcm/plugins/dcm/src/index.ts b/workspaces/dcm/plugins/dcm/src/index.ts index 7b0b2e69983..da346ade27d 100644 --- a/workspaces/dcm/plugins/dcm/src/index.ts +++ b/workspaces/dcm/plugins/dcm/src/index.ts @@ -18,7 +18,7 @@ export { dcmPlugin, DcmPage } from './plugin'; export { catalogApiRef, policyManagerApiRef, - providersApiRef, + agentsApiRef, resourcesApiRef, } from './apis'; export { isDarkMode, useIsDarkMode } from './components/dcmTheme'; diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx new file mode 100644 index 00000000000..3970c2fd257 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import type { Agent } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { agentsApiRef } from '../../apis'; +import { AgentsTabContent } from './AgentsTabContent'; + +jest.mock('../../hooks/useTranslation', () => { + const mod = require('../../test-utils/mockTranslations'); + return { useTranslation: mod.mockUseTranslation }; +}); + +const MOCK_AGENT: Agent = { + agent_id: 'a1b2c3d4', + name: 'env-agent-west-1', + environment: 'production', + service_types: ['vm', 'container'], + cost: 'medium', + topic_name: 'dcm.agent.env-agent-west-1', + health_status: 'ready', +}; + +const baseAgentsApi = { + listAgents: jest.fn().mockResolvedValue({ + agents: [MOCK_AGENT], + next_page_token: undefined, + }), + createAgent: jest.fn(), + getAgent: jest.fn(), + agentHeartbeat: jest.fn(), +}; + +function buildApis(overrides: Partial = {}) { + return { agents: { ...baseAgentsApi, ...overrides } }; +} + +async function renderAgentsTab( + apis: ReturnType = buildApis(), +) { + return renderInTestApp( + + + , + ); +} + +describe('AgentsTabContent', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('initial load', () => { + it('calls listAgents with pagination params on mount', async () => { + const apis = buildApis(); + await renderAgentsTab(apis); + + await waitFor(() => + expect(apis.agents.listAgents).toHaveBeenCalledTimes(1), + ); + expect(apis.agents.listAgents).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: expect.any(Number) }), + ); + }); + + it('shows agent name in the table after successful load', async () => { + const apis = buildApis(); + await renderAgentsTab(apis); + + expect(await screen.findByText('env-agent-west-1')).toBeInTheDocument(); + }); + + it('shows the empty state when no agents are returned', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockResolvedValue({ agents: [] }), + }); + await renderAgentsTab(apis); + + expect( + await screen.findByText(/no agents registered/i), + ).toBeInTheDocument(); + }); + }); + + describe('load error', () => { + it('shows an error alert when listAgents rejects', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockRejectedValue(new Error('API down')), + }); + await renderAgentsTab(apis); + + expect(await screen.findByText(/API down/i)).toBeInTheDocument(); + }); + + it('shows a Retry button when listAgents rejects', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockRejectedValue(new Error('API down')), + }); + await renderAgentsTab(apis); + + expect( + await screen.findByRole('button', { name: /retry/i }), + ).toBeInTheDocument(); + }); + }); + + describe('cursor pagination', () => { + it('shows Next button when next_page_token is returned', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockResolvedValue({ + agents: [MOCK_AGENT], + next_page_token: 'tok-2', + }), + }); + await renderAgentsTab(apis); + + expect( + await screen.findByRole('button', { name: /next/i }), + ).toBeInTheDocument(); + }); + + it('Next button is disabled when no next_page_token', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockResolvedValue({ + agents: [MOCK_AGENT], + next_page_token: '', + }), + }); + await renderAgentsTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + expect(nextBtn).toBeDisabled(); + }); + + it('calls listAgents with next_page_token after clicking Next', async () => { + const listAgents = jest + .fn() + .mockResolvedValueOnce({ + agents: [MOCK_AGENT], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + agents: [MOCK_AGENT], + next_page_token: '', + }); + const apis = buildApis({ listAgents }); + await renderAgentsTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect(listAgents).toHaveBeenCalledWith( + expect.objectContaining({ page_token: 'tok-2' }), + ), + ); + }); + + it('Previous button is disabled on the first page', async () => { + const apis = buildApis({ + listAgents: jest.fn().mockResolvedValue({ + agents: [MOCK_AGENT], + next_page_token: 'tok-2', + }), + }); + await renderAgentsTab(apis); + + const prevBtn = await screen.findByRole('button', { name: /previous/i }); + expect(prevBtn).toBeDisabled(); + }); + + it('Previous button is enabled after navigating to page 2', async () => { + const listAgents = jest + .fn() + .mockResolvedValueOnce({ + agents: [MOCK_AGENT], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + agents: [MOCK_AGENT], + next_page_token: '', + }); + const apis = buildApis({ listAgents }); + await renderAgentsTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /previous/i }), + ).not.toBeDisabled(), + ); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx new file mode 100644 index 00000000000..b54f743025f --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx @@ -0,0 +1,223 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo } from 'react'; +import { TableColumn } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { Box, Chip, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +import type { Agent } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { agentsApiRef } from '../../apis'; +import { DcmCrudTabLayout } from '../../components/DcmCrudTabLayout'; +import { DcmFormDialog } from '../../components/DcmFormDialog'; +import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; +import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; +import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; +import { useTranslation } from '../../hooks/useTranslation'; +import emptyIllustration from '../../assets/environments-empty-state.png'; +import { TruncatedText, DcmEmptyCell } from '../../components/TruncatedText'; +import { AgentHealthStatus } from './components/AgentHealthStatus'; +import { AgentFormFields } from './components/AgentFormFields'; +import { CopyButton } from './components/CopyButton'; +import { + emptyAgentForm, + formToAgentRegistration, + isAgentFormValid, +} from './agentFormTypes'; +import type { AgentForm } from './agentFormTypes'; + +const useStyles = makeStyles(theme => ({ + nameCellBox: { + minWidth: 0, + }, + chipCell: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + }, +})); + +export function AgentsTabContent() { + const classes = useStyles(); + const agentsApi = useApi(agentsApiRef); + const { t } = useTranslation(); + + const crud = usePaginatedCrudTab({ + loadFn: ({ pageToken, pageSize: ps }) => + agentsApi + .listAgents({ page_token: pageToken, max_page_size: ps }) + .then(r => ({ + items: r.agents ?? [], + nextPageToken: r.next_page_token, + })), + storageKey: 'agents', + createFn: form => agentsApi.createAgent(formToAgentRegistration(form)), + getId: a => a.agent_id ?? a.name ?? '', + getSearchText: a => [a.name, a.environment, a.topic_name], + emptyForm: emptyAgentForm, + isValid: isAgentFormValid, + createSuccessMessage: t('agents.createSuccess'), + }); + + const columns = useMemo[]>( + () => [ + { + title: t('agents.columns.name'), + field: 'name', + render: a => ( + + } + /> + {a.agent_id && ( + } + /> + )} + + ), + }, + { + title: t('agents.columns.environment'), + field: 'environment', + render: a => ( + + ), + }, + { + title: t('agents.columns.serviceTypes'), + field: 'service_types', + sorting: false, + render: a => { + const types = a.service_types ?? []; + const VISIBLE = 2; + const visible = types.slice(0, VISIBLE); + const rest = types.slice(VISIBLE); + return ( + + {visible.map(st => ( + + ))} + {rest.length > 0 && ( + + + + )} + + ); + }, + }, + { + title: t('agents.columns.cost'), + field: 'cost', + render: a => , + }, + { + title: t('agents.columns.topic'), + field: 'topic_name', + render: a => ( + + } + /> + {a.topic_name && } + + ), + }, + { + title: t('agents.columns.health'), + field: 'health_status', + render: a => , + }, + ], + [classes, t], + ); + + return ( + <> + + items={crud.items} + filtered={crud.filtered} + paginated={crud.filtered} + columns={columns} + loading={crud.loading} + loadError={crud.loadError} + onRetry={crud.reload} + search={crud.search} + onSearchChange={crud.handleSearchChange} + cursorPagination={crud.cursorPagination} + emptyTitle={t('agents.emptyTitle')} + emptyDescription={t('agents.emptyDescription')} + primaryActionLabel={t('agents.registerButton')} + onPrimaryAction={crud.handleOpenCreate} + illustrationSrc={emptyIllustration} + entityLabel={t('agents.entityLabel')} + /> + + + } + > + + + + + + ); +} diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts new file mode 100644 index 00000000000..159b7540558 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Agent } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { + agentToForm, + emptyAgentForm, + formToAgentRegistration, + isAgentFormValid, + validateAgentForm, + type AgentForm, +} from './agentFormTypes'; + +const VALID_FORM: AgentForm = { + name: 'env-agent-west-1', + environment: 'production', + service_types: ['vm', 'container'], + cost: 'medium', + topic_name: 'dcm.agent.env-agent-west-1', +}; + +describe('emptyAgentForm', () => { + it('returns empty strings and empty array', () => { + const form = emptyAgentForm(); + expect(form.name).toBe(''); + expect(form.environment).toBe(''); + expect(form.service_types).toEqual([]); + expect(form.cost).toBe(''); + expect(form.topic_name).toBe(''); + }); +}); + +describe('isAgentFormValid', () => { + it('returns true for a fully valid form', () => { + expect(isAgentFormValid(VALID_FORM)).toBe(true); + }); + + it('returns false when name is empty', () => { + expect(isAgentFormValid({ ...VALID_FORM, name: '' })).toBe(false); + }); + + it('returns false when name starts with a digit', () => { + expect(isAgentFormValid({ ...VALID_FORM, name: '1abc' })).toBe(false); + }); + + it('returns false when environment is empty', () => { + expect(isAgentFormValid({ ...VALID_FORM, environment: '' })).toBe(false); + }); + + it('returns false when service_types is empty', () => { + expect(isAgentFormValid({ ...VALID_FORM, service_types: [] })).toBe(false); + }); + + it('returns false when cost is empty', () => { + expect(isAgentFormValid({ ...VALID_FORM, cost: '' })).toBe(false); + }); + + it('returns false when topic_name is empty', () => { + expect(isAgentFormValid({ ...VALID_FORM, topic_name: '' })).toBe(false); + }); + + it('returns false when topic_name does not start with dcm.agent.', () => { + expect( + isAgentFormValid({ ...VALID_FORM, topic_name: 'other.topic.name' }), + ).toBe(false); + }); +}); + +describe('validateAgentForm', () => { + it('returns empty errors for a valid form', () => { + expect(validateAgentForm(VALID_FORM)).toEqual({}); + }); + + it('returns nameRequired error for empty name', () => { + const errors = validateAgentForm({ ...VALID_FORM, name: '' }); + expect(errors.name).toBeDefined(); + }); + + it('returns namePattern error for invalid name characters', () => { + const errors = validateAgentForm({ ...VALID_FORM, name: 'My Agent' }); + expect(errors.name).toBeDefined(); + }); + + it('returns topicNamePattern error for bad topic_name', () => { + const errors = validateAgentForm({ + ...VALID_FORM, + topic_name: 'not-a-dcm-topic', + }); + expect(errors.topic_name).toBeDefined(); + }); +}); + +describe('agentToForm', () => { + it('maps Agent to AgentForm correctly', () => { + const agent: Agent = { + name: 'my-agent', + environment: 'staging', + service_types: ['vm'], + cost: 'low', + topic_name: 'dcm.agent.my-agent', + agent_id: 'abc-123', + health_status: 'ready', + }; + + const form = agentToForm(agent); + + expect(form).toEqual({ + name: 'my-agent', + environment: 'staging', + service_types: ['vm'], + cost: 'low', + topic_name: 'dcm.agent.my-agent', + }); + }); +}); + +describe('formToAgentRegistration', () => { + it('maps AgentForm to AgentRegistrationRequest correctly', () => { + const req = formToAgentRegistration(VALID_FORM); + + expect(req).toEqual({ + name: 'env-agent-west-1', + environment: 'production', + service_types: ['vm', 'container'], + cost: 'medium', + topic_name: 'dcm.agent.env-agent-west-1', + }); + }); + + it('trims whitespace from name and topic_name', () => { + const req = formToAgentRegistration({ + ...VALID_FORM, + name: ' my-agent ', + topic_name: ' dcm.agent.my-agent ', + }); + + expect(req.name).toBe('my-agent'); + expect(req.topic_name).toBe('dcm.agent.my-agent'); + }); + + it('filters empty strings from service_types', () => { + const req = formToAgentRegistration({ + ...VALID_FORM, + service_types: ['vm', '', 'container'], + }); + + expect(req.service_types).toEqual(['vm', 'container']); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts new file mode 100644 index 00000000000..ea1c60b8247 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts @@ -0,0 +1,131 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as yup from 'yup'; +import type { + Agent, + AgentCost, + AgentRegistrationRequest, +} from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { createYupValidator } from '../../utils/createYupValidator'; +import { type TFunction, makeTranslator } from '../../utils/formUtils'; + +export type AgentForm = { + name: string; + environment: string; + service_types: string[]; + cost: AgentCost | ''; + topic_name: string; +}; + +export const AGENT_COST_OPTIONS: AgentCost[] = [ + 'low', + 'medium-low', + 'medium', + 'medium-high', + 'high', +]; + +function buildAgentSchema(t?: TFunction) { + const m = makeTranslator(t); + return yup.object({ + name: yup + .string() + .required(m('validation.agent.nameRequired', 'Name is required')) + .matches( + /^[a-z][a-z0-9-]*$/, + m( + 'validation.agent.namePattern', + 'Only lowercase letters, numbers, and hyphens are allowed (must start with a letter)', + ), + ), + environment: yup + .string() + .required( + m('validation.agent.environmentRequired', 'Environment is required'), + ), + service_types: yup + .array() + .of(yup.string().required()) + .min( + 1, + m( + 'validation.agent.serviceTypesRequired', + 'At least one service type is required', + ), + ), + cost: yup + .string() + .required(m('validation.agent.costRequired', 'Cost is required')), + topic_name: yup + .string() + .required( + m('validation.agent.topicNameRequired', 'Topic name is required'), + ) + .matches( + /^dcm\.agent\..+/, + m( + 'validation.agent.topicNamePattern', + 'Topic name must start with dcm.agent.', + ), + ), + }); +} + +export function validateAgentForm( + form: AgentForm, + t?: TFunction, +): Partial> { + const { validate } = createYupValidator(buildAgentSchema(t)); + return validate(form); +} + +export function isAgentFormValid(form: AgentForm): boolean { + const { isValid } = createYupValidator(buildAgentSchema()); + return isValid(form); +} + +export function emptyAgentForm(): AgentForm { + return { + name: '', + environment: '', + service_types: [], + cost: '', + topic_name: '', + }; +} + +export function agentToForm(a: Agent): AgentForm { + return { + name: a.name ?? '', + environment: a.environment ?? '', + service_types: a.service_types ?? [], + cost: a.cost ?? '', + topic_name: a.topic_name ?? '', + }; +} + +export function formToAgentRegistration( + f: AgentForm, +): AgentRegistrationRequest { + return { + name: f.name.trim(), + environment: f.environment.trim(), + service_types: f.service_types.map(s => s.trim()).filter(Boolean), + cost: f.cost as AgentCost, + topic_name: f.topic_name.trim(), + }; +} diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx new file mode 100644 index 00000000000..bf3c4521de5 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo } from 'react'; +import { + Box, + Chip, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + OutlinedInput, + Select, + TextField, +} from '@material-ui/core'; +import { + AGENT_COST_OPTIONS, + AgentForm, + validateAgentForm, +} from '../agentFormTypes'; +import { useTranslation } from '../../../hooks/useTranslation'; + +type TouchedMap = Partial>; + +export type AgentFormFieldsProps = Readonly<{ + form: AgentForm; + setForm: React.Dispatch>; + touched: TouchedMap; + setTouched: React.Dispatch>; +}>; + +export function AgentFormFields({ + form, + setForm, + touched, + setTouched, +}: AgentFormFieldsProps) { + const { t } = useTranslation(); + const errors = useMemo(() => validateAgentForm(form, t), [form, t]); + + const touch = (field: keyof AgentForm) => + setTouched(prev => ({ ...prev, [field]: true })); + + const err = (field: keyof AgentForm) => + touched[field] ? errors[field] : undefined; + + return ( + + setForm(prev => ({ ...prev, name: e.target.value }))} + onBlur={() => touch('name')} + fullWidth + variant="outlined" + size="small" + placeholder={t('agents.form.namePlaceholder')} + /> + + + setForm(prev => ({ ...prev, environment: e.target.value })) + } + onBlur={() => touch('environment')} + fullWidth + variant="outlined" + size="small" + placeholder={t('agents.form.environmentPlaceholder')} + /> + + + {t('agents.form.serviceTypesLabel')} + + + {err('service_types') ?? t('agents.form.serviceTypesHelper')} + + + + + {t('agents.form.costLabel')} + + + {err('cost') ?? t('agents.form.costHelper')} + + + + + setForm(prev => ({ ...prev, topic_name: e.target.value })) + } + onBlur={() => touch('topic_name')} + fullWidth + variant="outlined" + size="small" + placeholder={t('agents.form.topicNamePlaceholder')} + /> + + ); +} diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderStatus.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx similarity index 57% rename from workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderStatus.tsx rename to workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx index 896e3a1d463..29fcc3bf977 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderStatus.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx @@ -18,9 +18,6 @@ import { StatusOK, StatusWarning, StatusError, - StatusAborted, - StatusPending, - StatusRunning, } from '@backstage/core-components'; import { Box, makeStyles, Typography } from '@material-ui/core'; @@ -46,17 +43,16 @@ const useStyles = makeStyles(theme => ({ })); /** - * Maps a provider `health_status` string to a Backstage Status component. + * Maps an agent `health_status` value to a Backstage Status component. * * Mapping: - * ready / ok / healthy / active → StatusOK - * degraded / warning → StatusWarning - * error / failed / unhealthy → StatusError - * running / starting → StatusRunning - * aborted / terminated / deleted → StatusAborted - * not_ready / pending / (other) → StatusPending + * ready → StatusOK + * congested → StatusWarning + * unavailable → StatusError */ -export function ProviderStatus({ value }: Readonly<{ value?: string }>) { +export function AgentHealthStatus({ + value, +}: Readonly<{ value?: string | null }>) { const classes = useStyles(); if (!value) { @@ -67,21 +63,18 @@ export function ProviderStatus({ value }: Readonly<{ value?: string }>) { ); } - const normalised = value.toLowerCase().replaceAll(/[_-]/g, ''); - let StatusComponent: React.ElementType; - if (['ready', 'ok', 'healthy', 'active'].includes(normalised)) { - StatusComponent = StatusOK; - } else if (['degraded', 'warning'].includes(normalised)) { - StatusComponent = StatusWarning; - } else if (['error', 'failed', 'unhealthy'].includes(normalised)) { - StatusComponent = StatusError; - } else if (['running', 'starting'].includes(normalised)) { - StatusComponent = StatusRunning; - } else if (['aborted', 'terminated', 'deleted'].includes(normalised)) { - StatusComponent = StatusAborted; - } else { - StatusComponent = StatusPending; + switch (value.toLowerCase()) { + case 'ready': + StatusComponent = StatusOK; + break; + case 'congested': + StatusComponent = StatusWarning; + break; + case 'unavailable': + default: + StatusComponent = StatusError; + break; } return ( diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx new file mode 100644 index 00000000000..845c2008430 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { IconButton, Tooltip } from '@material-ui/core'; +import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; +import { useTranslation } from '../../../hooks/useTranslation'; + +type CopyState = 'idle' | 'copied' | 'failed'; + +export function CopyButton({ text }: Readonly<{ text: string }>) { + const { t } = useTranslation(); + const [state, setState] = useState('idle'); + + const handleCopy = async () => { + try { + await window.navigator.clipboard.writeText(text); + setState('copied'); + } catch { + setState('failed'); + } finally { + setTimeout(() => setState('idle'), 2000); + } + }; + + let label: string; + if (state === 'copied') { + label = t('copyButton.copied'); + } else if (state === 'failed') { + label = t('copyButton.failed'); + } else { + label = t('copyButton.copy'); + } + + return ( + + + + + + ); +} diff --git a/workspaces/dcm/plugins/dcm/src/pages/data-center/DataCenterPage.tsx b/workspaces/dcm/plugins/dcm/src/pages/data-center/DataCenterPage.tsx index bd45dacb719..c51835511cb 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/data-center/DataCenterPage.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/data-center/DataCenterPage.tsx @@ -22,7 +22,7 @@ import { } from '@backstage/core-components'; import { Box, Divider, makeStyles, Typography } from '@material-ui/core'; -import { ProvidersTabContent } from '../providers/ProvidersTabContent'; +import { AgentsTabContent } from '../agents/AgentsTabContent'; import { PoliciesTabContent } from '../policies/PoliciesTabContent'; import { ServiceTypesTabContent } from '../service-types/ServiceTypesTabContent'; import { CatalogItemsTabContent } from '../catalog-items/CatalogItemsTabContent'; @@ -79,8 +79,8 @@ export const DataCenterPage = () => { - - + + { - const mod = require('../../test-utils/mockTranslations'); - return { useTranslation: mod.mockUseTranslation }; -}); - -const MOCK_PROVIDER: Provider = { - id: 'provider-1', - name: 'my-provider', - display_name: 'My Provider', - endpoint: 'http://example.com', - service_type: 'vm', - schema_version: 'v1alpha1', -}; - -const MOCK_SERVICE_TYPE: ServiceType = { - uid: 'st-1', - service_type: 'vm', - api_version: 'v1alpha1', - spec: {}, -}; - -const baseProvidersApi = { - listProviders: jest.fn().mockResolvedValue({ - providers: [MOCK_PROVIDER], - next_page_token: undefined, - }), - createProvider: jest.fn(), - applyProvider: jest.fn(), - deleteProvider: jest.fn(), - getProvider: jest.fn(), -}; - -const baseCatalogApi = { - listServiceTypes: jest - .fn() - .mockResolvedValue({ results: [MOCK_SERVICE_TYPE] }), - listCatalogItems: jest.fn().mockResolvedValue({ results: [] }), - listCatalogItemInstances: jest.fn().mockResolvedValue({ results: [] }), - getCatalogItem: jest.fn(), - getCatalogItemInstance: jest.fn(), - getServiceType: jest.fn(), - createServiceType: jest.fn(), - createCatalogItem: jest.fn(), - updateCatalogItem: jest.fn(), - deleteCatalogItem: jest.fn(), - createCatalogItemInstance: jest.fn(), - deleteCatalogItemInstance: jest.fn(), - rehydrateCatalogItemInstance: jest.fn(), -}; - -function buildApis( - providerOverrides: Partial = {}, - catalogOverrides: Partial = {}, -) { - return { - providers: { ...baseProvidersApi, ...providerOverrides }, - catalog: { ...baseCatalogApi, ...catalogOverrides }, - }; -} - -async function renderProvidersTab( - apis: ReturnType = buildApis(), -) { - return renderInTestApp( - - - , - ); -} - -describe('ProvidersTabContent', () => { - beforeEach(() => jest.clearAllMocks()); - - describe('initial load', () => { - it('calls listProviders with pagination params on mount', async () => { - const apis = buildApis(); - await renderProvidersTab(apis); - - await waitFor(() => - expect(apis.providers.listProviders).toHaveBeenCalledTimes(1), - ); - expect(apis.providers.listProviders).toHaveBeenCalledWith( - expect.objectContaining({ max_page_size: expect.any(Number) }), - ); - }); - - it('calls listServiceTypes with max_page_size: 100 for the dropdown (once on mount)', async () => { - const apis = buildApis(); - await renderProvidersTab(apis); - - await waitFor(() => - expect(apis.catalog.listServiceTypes).toHaveBeenCalledTimes(1), - ); - expect(apis.catalog.listServiceTypes).toHaveBeenCalledWith({ - max_page_size: 100, - }); - }); - - it('shows provider name in the table after successful load', async () => { - const apis = buildApis(); - await renderProvidersTab(apis); - - expect(await screen.findByText('my-provider')).toBeInTheDocument(); - }); - - it('shows the empty state when no providers are returned', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockResolvedValue({ providers: [] }), - }); - await renderProvidersTab(apis); - - expect( - await screen.findByText(/no providers registered/i), - ).toBeInTheDocument(); - }); - }); - - describe('load error', () => { - it('shows an error alert when listProviders rejects', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockRejectedValue(new Error('API down')), - }); - await renderProvidersTab(apis); - - expect(await screen.findByText(/API down/i)).toBeInTheDocument(); - }); - - it('shows a Retry button when listProviders rejects', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockRejectedValue(new Error('API down')), - }); - await renderProvidersTab(apis); - - expect( - await screen.findByRole('button', { name: /retry/i }), - ).toBeInTheDocument(); - }); - }); - - describe('cursor pagination', () => { - it('shows Next button when next_page_token is returned', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockResolvedValue({ - providers: [MOCK_PROVIDER], - next_page_token: 'tok-2', - }), - }); - await renderProvidersTab(apis); - - expect( - await screen.findByRole('button', { name: /next/i }), - ).toBeInTheDocument(); - }); - - it('Next button is disabled when no next_page_token', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockResolvedValue({ - providers: [MOCK_PROVIDER], - next_page_token: '', - }), - }); - await renderProvidersTab(apis); - - const nextBtn = await screen.findByRole('button', { name: /next/i }); - expect(nextBtn).toBeDisabled(); - }); - - it('calls listProviders with next_page_token after clicking Next', async () => { - const listProviders = jest - .fn() - .mockResolvedValueOnce({ - providers: [MOCK_PROVIDER], - next_page_token: 'tok-2', - }) - .mockResolvedValueOnce({ - providers: [MOCK_PROVIDER], - next_page_token: '', - }); - const apis = buildApis({ listProviders }); - await renderProvidersTab(apis); - - const nextBtn = await screen.findByRole('button', { name: /next/i }); - fireEvent.click(nextBtn); - - await waitFor(() => - expect(listProviders).toHaveBeenCalledWith( - expect.objectContaining({ page_token: 'tok-2' }), - ), - ); - }); - - it('Previous button is disabled on the first page', async () => { - const apis = buildApis({ - listProviders: jest.fn().mockResolvedValue({ - providers: [MOCK_PROVIDER], - next_page_token: 'tok-2', - }), - }); - await renderProvidersTab(apis); - - const prevBtn = await screen.findByRole('button', { name: /previous/i }); - expect(prevBtn).toBeDisabled(); - }); - - it('shows the current page size in the rows-per-page selector', async () => { - const apis = buildApis(); - await renderProvidersTab(apis); - - await screen.findByText('my-provider'); - // The Select renders the selected value as "5 rows" via renderValue. - expect(screen.getByText('5 rows')).toBeInTheDocument(); - }); - - it('re-fetches with new max_page_size when the page-size option is selected', async () => { - const listProviders = jest - .fn() - .mockResolvedValue({ providers: [MOCK_PROVIDER], next_page_token: '' }); - const apis = buildApis({ listProviders }); - await renderProvidersTab(apis); - - await screen.findByText('my-provider'); - expect(listProviders).toHaveBeenCalledTimes(1); - - // Open the MUI Select by clicking its trigger button (displays current size). - // The trigger renders as a button inside the pagination controls. - fireEvent.mouseDown(screen.getByRole('button', { name: /rows/i })); - - // Pick "10" from the opened dropdown menu. - const option10 = await screen.findByRole('option', { name: '10' }); - fireEvent.click(option10); - - await waitFor(() => - expect(listProviders).toHaveBeenCalledWith( - expect.objectContaining({ max_page_size: 10 }), - ), - ); - }); - - it('Previous button is enabled after navigating to page 2', async () => { - const listProviders = jest - .fn() - .mockResolvedValueOnce({ - providers: [MOCK_PROVIDER], - next_page_token: 'tok-2', - }) - .mockResolvedValueOnce({ - providers: [MOCK_PROVIDER], - next_page_token: '', - }); - const apis = buildApis({ listProviders }); - await renderProvidersTab(apis); - - const nextBtn = await screen.findByRole('button', { name: /next/i }); - fireEvent.click(nextBtn); - - await waitFor(() => - expect( - screen.getByRole('button', { name: /previous/i }), - ).not.toBeDisabled(), - ); - }); - }); -}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx deleted file mode 100644 index 74e2581c8de..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx +++ /dev/null @@ -1,377 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useEffect, useMemo, useState } from 'react'; -import { TableColumn } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; -import { Box, Chip, Tooltip, Typography } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; - -const useStyles = makeStyles(theme => ({ - nameCellBox: { - minWidth: 0, - }, - serviceTypeChip: { - maxWidth: 160, - overflow: 'hidden', - }, - operationsCellBox: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - }, - moreOpsChip: { - cursor: 'default', - }, -})); -import type { - Provider, - ServiceType, -} from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { extractApiError } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { catalogApiRef, providersApiRef } from '../../apis'; -import { DcmCrudTabLayout } from '../../components/DcmCrudTabLayout'; -import { DcmDeleteDialog } from '../../components/DcmDeleteDialog'; -import { DcmFormDialog } from '../../components/DcmFormDialog'; -import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; -import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; -import { createEditDeleteColumn } from '../../components/dcmTabListHelpers'; -import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; -import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; -import { useTranslation } from '../../hooks/useTranslation'; -import emptyIllustration from '../../assets/environments-empty-state.png'; -import { CopyButton } from './components/CopyButton'; -import { ProviderFormFields } from './components/ProviderFormFields'; -import { ProviderStatus } from './components/ProviderStatus'; -import { - emptyProviderForm, - formToProvider, - isProviderFormValid, - nameToDisplayName, - providerToForm, -} from './providerFormTypes'; -import type { ProviderForm } from './providerFormTypes'; - -export function ProvidersTabContent() { - const classes = useStyles(); - const providersApi = useApi(providersApiRef); - const catalogApi = useApi(catalogApiRef); - const { t } = useTranslation(); - - const [serviceTypes, setServiceTypes] = useState([]); - const [serviceTypesError, setServiceTypesError] = useState( - null, - ); - - // Fetch dropdown options once on mount; page navigation does not re-fetch. - useEffect(() => { - catalogApi - .listServiceTypes({ max_page_size: 100 }) - .then(r => setServiceTypes(r.results ?? [])) - .catch(err => setServiceTypesError(extractApiError(err))); - }, [catalogApi]); - - const crud = usePaginatedCrudTab({ - loadFn: ({ pageToken, pageSize: ps }) => - providersApi - .listProviders({ page_token: pageToken, max_page_size: ps }) - .then(r => ({ - items: r.providers ?? [], - nextPageToken: r.next_page_token, - })), - storageKey: 'providers', - createFn: form => providersApi.createProvider(formToProvider(form)), - updateFn: (id, form) => - providersApi.applyProvider(id, formToProvider(form)), - deleteFn: id => providersApi.deleteProvider(id), - getId: p => p.id ?? p.name ?? '', - getSearchText: p => [p.name, p.display_name, p.service_type, p.endpoint], - emptyForm: emptyProviderForm, - isValid: isProviderFormValid, - itemToForm: providerToForm, - createSuccessMessage: t('providers.createSuccess'), - editSuccessMessage: t('providers.updateSuccess'), - deleteSuccessMessage: t('providers.deleteSuccess'), - }); - - const columns = useMemo[]>( - () => [ - { - title: t('providers.columns.displayName'), - field: 'display_name', - render: p => ( - - } - /> - {p.id && ( - } - /> - )} - - ), - }, - { - title: t('providers.columns.name'), - field: 'name', - render: p => ( - } - /> - ), - }, - { - title: t('providers.columns.endpoint'), - field: 'endpoint', - render: p => ( - - } - /> - {p.endpoint && } - - ), - }, - { - title: t('providers.columns.serviceType'), - field: 'service_type', - render: p => ( - - ), - }, - { - title: t('providers.columns.operations'), - field: 'operations', - sorting: false, - render: p => { - const raw: unknown = p.operations; - let ops: string[]; - if (Array.isArray(raw)) { - ops = raw as string[]; - } else if (typeof raw === 'string' && raw.trim()) { - ops = raw - .split(',') - .map((s: string) => s.trim()) - .filter(Boolean); - } else { - ops = []; - } - - if (ops.length === 0) { - return ( - - - - - ); - } - - const VISIBLE = 2; - const visible = ops.slice(0, VISIBLE); - const rest = ops.slice(VISIBLE); - - return ( - - {visible.map(op => ( - - ))} - {rest.length > 0 && ( - - - - )} - - ); - }, - }, - { - title: t('providers.columns.status'), - field: 'health_status', - render: p => , - }, - createEditDeleteColumn({ - onEdit: crud.handleOpenEdit, - onDelete: crud.handleOpenDelete, - title: t('common.actions'), - }), - ], - [classes, crud.handleOpenEdit, crud.handleOpenDelete, t], - ); - - type ProviderDialogProps = { - title: string; - open: boolean; - onClose: () => void; - form: ProviderForm; - setForm: React.Dispatch>; - touched: Partial>; - setTouched: React.Dispatch< - React.SetStateAction>> - >; - onSubmit: () => void; - submitLabel: string; - submitting: boolean; - error: string | null; - isEditMode?: boolean; - }; - - const formDialog = ({ - title, - open, - onClose, - form, - setForm, - touched, - setTouched, - onSubmit, - submitLabel, - submitting, - error, - isEditMode, - }: ProviderDialogProps) => ( - - } - > - - - ); - - return ( - <> - - items={crud.items} - filtered={crud.filtered} - paginated={crud.filtered} - columns={columns} - loading={crud.loading} - loadError={crud.loadError} - onRetry={crud.reload} - actionError={serviceTypesError} - onDismissActionError={() => setServiceTypesError(null)} - search={crud.search} - onSearchChange={crud.handleSearchChange} - cursorPagination={crud.cursorPagination} - emptyTitle={t('providers.emptyTitle')} - emptyDescription={t('providers.emptyDescription')} - primaryActionLabel={t('providers.registerButton')} - onPrimaryAction={crud.handleOpenCreate} - illustrationSrc={emptyIllustration} - entityLabel={t('providers.entityLabel')} - /> - - {formDialog({ - title: t('providers.registerDialogTitle'), - open: crud.createOpen, - onClose: crud.handleCloseCreate, - form: crud.createForm, - setForm: crud.setCreateForm, - touched: crud.createTouched, - setTouched: crud.setCreateTouched, - onSubmit: crud.handleCreateSubmit, - submitLabel: t('providers.registerButton'), - submitting: crud.createSubmitting, - error: crud.createError, - })} - - {formDialog({ - title: t('providers.editDialogTitle'), - open: crud.editOpen, - onClose: crud.handleCloseEdit, - form: crud.editForm, - setForm: crud.setEditForm, - touched: crud.editTouched, - setTouched: crud.setEditTouched, - onSubmit: crud.handleEditSubmit, - submitLabel: t('providers.saveButton'), - submitting: crud.editSubmitting, - error: crud.editError, - isEditMode: true, - })} - - - - - - ); -} diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx deleted file mode 100644 index fa83ba04ba5..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { CopyButton } from './CopyButton'; - -jest.mock('../../../hooks/useTranslation', () => { - const mod = require('../../../test-utils/mockTranslations'); - return { useTranslation: mod.mockUseTranslation }; -}); - -const writeTextMock = jest.fn(); - -beforeAll(() => { - Object.assign(globalThis.navigator, { - clipboard: { writeText: writeTextMock }, - }); -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('CopyButton', () => { - it('renders the copy button with the default aria-label', () => { - render(); - expect( - screen.getByRole('button', { name: /copy to clipboard/i }), - ).toBeInTheDocument(); - }); - - it('shows checkmark and Copied! tooltip after a successful copy', async () => { - writeTextMock.mockResolvedValue(undefined); - render(); - - fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); - - await waitFor(() => - expect(writeTextMock).toHaveBeenCalledWith('https://example.com'), - ); - - // After success the button title changes — MUI Tooltip sets aria-label on - // the element passed to it; we can verify the icon switch via aria-label - // on the wrapping Tooltip span via title attribute propagation. - // A simpler check: the error icon must NOT be present. - expect(screen.queryByTestId('ErrorOutlineIcon')).not.toBeInTheDocument(); - }); - - it('shows error icon after a failed clipboard write', async () => { - writeTextMock.mockRejectedValue(new Error('Permission denied')); - render(); - - fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); - - await waitFor(() => - expect(writeTextMock).toHaveBeenCalledWith('https://example.com'), - ); - - // After failure the ErrorOutlineIcon should be rendered - await waitFor(() => - expect( - document.querySelector('[data-testid="ErrorOutlineIcon"]') || - document.querySelector('.MuiSvgIcon-root'), - ).toBeTruthy(), - ); - }); - - it('passes the text prop to clipboard.writeText', async () => { - writeTextMock.mockResolvedValue(undefined); - render(); - - fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); - - await waitFor(() => - expect(writeTextMock).toHaveBeenCalledWith('some-endpoint-url'), - ); - }); -}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx deleted file mode 100644 index f034f9abce6..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState } from 'react'; -import { IconButton, Tooltip } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import CheckIcon from '@material-ui/icons/Check'; -import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; -import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; -import { useTranslation } from '../../../hooks/useTranslation'; - -const useStyles = makeStyles(theme => ({ - copyButton: { - padding: theme.spacing(0.25), - marginLeft: theme.spacing(0.5), - }, - copiedIcon: { - fontSize: 14, - color: theme.palette.success.main, - }, - failedIcon: { - fontSize: 14, - color: theme.palette.error.main, - }, - copyIcon: { - fontSize: 14, - }, -})); - -/** Icon button that copies text to the clipboard and shows a brief checkmark. */ -export function CopyButton({ text }: Readonly<{ text: string }>) { - const classes = useStyles(); - const { t } = useTranslation(); - const [copied, setCopied] = useState(false); - const [copyFailed, setCopyFailed] = useState(false); - - const handleCopy = () => { - globalThis.navigator.clipboard - .writeText(text) - .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }) - .catch(() => { - setCopyFailed(true); - setTimeout(() => setCopyFailed(false), 2000); - }); - }; - - let tooltipTitle = t('copyButton.copy'); - if (copied) tooltipTitle = t('copyButton.copied'); - else if (copyFailed) tooltipTitle = t('copyButton.failed'); - - let icon = ; - if (copied) icon = ; - else if (copyFailed) - icon = ; - - return ( - - - {icon} - - - ); -} diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.test.tsx deleted file mode 100644 index 06eb114d0fe..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useState } from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { - ProviderFormFields, - ProviderFormFieldsProps, -} from './ProviderFormFields'; -import { emptyProviderForm, ProviderForm } from '../providerFormTypes'; - -jest.mock('../../../hooks/useTranslation', () => { - const mod = require('../../../test-utils/mockTranslations'); - return { useTranslation: mod.mockUseTranslation }; -}); - -type TouchedMap = Partial>; - -function Wrapper( - props: Readonly>, -) { - const [form, setForm] = useState(emptyProviderForm()); - const [touched, setTouched] = useState({}); - return ( - - ); -} - -// MUI v4 TextField does not associate label/input via for/id, so we use -// placeholder text and display values to target inputs directly. -const NAME_PLACEHOLDER = 'e.g. my-k8s-provider'; -const ENDPOINT_PLACEHOLDER = 'https://api.example.com'; -const SCHEMA_VERSION_DEFAULT = 'v1alpha1'; - -describe('ProviderFormFields – create mode', () => { - it('renders all form fields', () => { - render(); - - expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).toBeInTheDocument(); - expect( - screen.getByPlaceholderText(ENDPOINT_PLACEHOLDER), - ).toBeInTheDocument(); - expect( - screen.getByDisplayValue(SCHEMA_VERSION_DEFAULT), - ).toBeInTheDocument(); - expect(screen.getAllByText(/service type \*/i).length).toBeGreaterThan(0); - expect(screen.getAllByText(/^operations$/i).length).toBeGreaterThan(0); - }); - - it('Name field is enabled by default', () => { - render(); - expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).not.toBeDisabled(); - }); - - it('shows the slug hint helper text for the Name field', () => { - render(); - expect( - screen.getByText( - /unique slug identifier — only lowercase letters, numbers, and hyphens/i, - ), - ).toBeInTheDocument(); - }); - - it('updates the Name field value when the user types', async () => { - render(); - const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); - await userEvent.type(nameInput, 'my-provider'); - expect(nameInput).toHaveValue('my-provider'); - }); - - it('shows a validation error after blurring Name with an invalid value', async () => { - render(); - const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); - await userEvent.type(nameInput, 'INVALID NAME'); - fireEvent.blur(nameInput); - expect( - screen.getByText( - /only lowercase letters, numbers, and hyphens are allowed/i, - ), - ).toBeInTheDocument(); - }); - - it('does not show a validation error for a valid Name value', async () => { - render(); - const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); - await userEvent.type(nameInput, 'my-k8s-provider'); - fireEvent.blur(nameInput); - expect( - screen.queryByText( - /only lowercase letters, numbers, and hyphens are allowed/i, - ), - ).not.toBeInTheDocument(); - }); -}); - -describe('ProviderFormFields – edit mode', () => { - it('Name field is disabled', () => { - render(); - expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).toBeDisabled(); - }); - - it('shows the immutability helper text for the Name field', () => { - render(); - expect( - screen.getByText(/provider name cannot be changed after creation/i), - ).toBeInTheDocument(); - }); - - it('does not show the slug hint helper text for the Name field', () => { - render(); - expect( - screen.queryByText( - /unique slug identifier — only lowercase letters, numbers, and hyphens/i, - ), - ).not.toBeInTheDocument(); - }); - - it('Endpoint field remains enabled', () => { - render(); - expect( - screen.getByPlaceholderText(ENDPOINT_PLACEHOLDER), - ).not.toBeDisabled(); - }); - - it('Schema version field remains enabled', () => { - render(); - expect(screen.getByDisplayValue(SCHEMA_VERSION_DEFAULT)).not.toBeDisabled(); - }); -}); - -describe('ProviderFormFields – service types dropdown', () => { - it('shows "No service types available" placeholder when list is empty', () => { - render(); - expect(screen.getByText(/no service types available/i)).toBeInTheDocument(); - }); - - it('shows "Create a service type first" helper text when list is empty', () => { - render(); - expect( - screen.getByText(/create a service type first in the service types tab/i), - ).toBeInTheDocument(); - }); - - it('renders provided service type options', async () => { - const serviceTypes = [ - { uid: '1', service_type: 'kubernetes', api_version: 'v1', spec: {} }, - { uid: '2', service_type: 'aws-ec2', api_version: 'v1', spec: {} }, - ]; - render(); - - fireEvent.mouseDown(screen.getByRole('button', { name: /service type/i })); - - expect(await screen.findByText('kubernetes')).toBeInTheDocument(); - expect(await screen.findByText('aws-ec2')).toBeInTheDocument(); - }); -}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.tsx deleted file mode 100644 index 5fbb8d21248..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/ProviderFormFields.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { useMemo } from 'react'; -import { - Box, - FormControl, - FormHelperText, - InputLabel, - MenuItem, - OutlinedInput, - Select, - TextField, -} from '@material-ui/core'; -import type { ServiceType } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { - KNOWN_OPERATIONS, - ProviderForm, - validateProviderForm, -} from '../providerFormTypes'; -import { useTranslation } from '../../../hooks/useTranslation'; - -type TouchedMap = Partial>; - -export type ProviderFormFieldsProps = Readonly<{ - form: ProviderForm; - setForm: React.Dispatch>; - serviceTypes: ServiceType[]; - touched: TouchedMap; - setTouched: React.Dispatch>; - isEditMode?: boolean; -}>; - -export function ProviderFormFields({ - form, - setForm, - serviceTypes, - touched, - setTouched, - isEditMode, -}: ProviderFormFieldsProps) { - const { t } = useTranslation(); - const errors = useMemo(() => validateProviderForm(form, t), [form, t]); - - const touch = (field: keyof ProviderForm) => - setTouched(prev => ({ ...prev, [field]: true })); - - const err = (field: keyof ProviderForm) => - touched[field] ? errors[field] : undefined; - - return ( - - setForm(prev => ({ ...prev, name: e.target.value }))} - onBlur={() => touch('name')} - fullWidth - variant="outlined" - size="small" - placeholder={t('providers.form.namePlaceholder')} - disabled={isEditMode} - /> - - setForm(prev => ({ ...prev, endpoint: e.target.value }))} - onBlur={() => touch('endpoint')} - fullWidth - variant="outlined" - size="small" - placeholder={t('providers.form.endpointPlaceholder')} - /> - - - {t('providers.form.serviceTypeLabel')} - - - {err('service_type') ?? - (serviceTypes.length === 0 - ? t('providers.form.serviceTypeHelperNoTypes') - : t('providers.form.serviceTypeHelperDefault'))} - - - - - setForm(prev => ({ ...prev, schema_version: e.target.value })) - } - onBlur={() => touch('schema_version')} - fullWidth - variant="outlined" - size="small" - /> - - - {t('providers.form.operationsLabel')} - - {t('providers.form.operationsHelper')} - - - ); -} diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.test.ts b/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.test.ts deleted file mode 100644 index 2beb2432614..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - emptyProviderForm, - formToProvider, - isProviderFormValid, - nameToDisplayName, - providerToForm, - validateProviderForm, -} from './providerFormTypes'; - -describe('nameToDisplayName', () => { - it('converts a slug to title case', () => { - expect(nameToDisplayName('my-k8s-provider')).toBe('My K8s Provider'); - }); - - it('handles a single word', () => { - expect(nameToDisplayName('alpha')).toBe('Alpha'); - }); - - it('trims trailing/leading hyphens gracefully', () => { - expect(nameToDisplayName('test')).toBe('Test'); - }); -}); - -describe('validateProviderForm', () => { - const valid = () => ({ - ...emptyProviderForm(), - name: 'my-provider', - endpoint: 'https://api.example.com', - service_type: 'kubernetes', - schema_version: 'v1alpha1', - }); - - it('returns no errors for a valid form', () => { - expect(validateProviderForm(valid())).toEqual({}); - }); - - it('requires a name matching the slug pattern', () => { - const errors = validateProviderForm({ ...valid(), name: 'My Provider' }); - expect(errors.name).toBeDefined(); - }); - - it('requires an endpoint starting with http(s)://', () => { - const errors = validateProviderForm({ ...valid(), endpoint: 'sftp://bad' }); - expect(errors.endpoint).toBeDefined(); - }); - - it('requires a service_type', () => { - const errors = validateProviderForm({ ...valid(), service_type: '' }); - expect(errors.service_type).toBeDefined(); - }); - - it('requires schema_version to match v[alpha|beta] pattern', () => { - const errors = validateProviderForm({ - ...valid(), - schema_version: 'version1', - }); - expect(errors.schema_version).toBeDefined(); - }); -}); - -describe('isProviderFormValid', () => { - it('returns true for a fully valid form', () => { - expect( - isProviderFormValid({ - name: 'my-provider', - endpoint: 'https://api:8080', - service_type: 'k8s', - schema_version: 'v1', - operations: [], - }), - ).toBe(true); - }); - - it('returns false when any field is invalid', () => { - expect( - isProviderFormValid({ - ...emptyProviderForm(), - endpoint: 'https://api:8080', - service_type: 'k8s', - schema_version: 'v1', - }), - ).toBe(false); - }); -}); - -describe('providerToForm / formToProvider round-trip', () => { - it('round-trips without data loss', () => { - const form = { - name: 'my-provider', - endpoint: 'https://api.example.com', - service_type: 'kubernetes', - schema_version: 'v1alpha1', - operations: ['create', 'delete'], - }; - const provider = formToProvider(form); - const back = providerToForm(provider); - expect(back.name).toBe(form.name); - expect(back.endpoint).toBe(form.endpoint); - expect(back.service_type).toBe(form.service_type); - expect(back.schema_version).toBe(form.schema_version); - expect(back.operations).toEqual(form.operations); - }); - - it('omits operations from the payload when empty', () => { - const provider = formToProvider({ - ...emptyProviderForm(), - name: 'x', - endpoint: 'https://x', - service_type: 'k8s', - schema_version: 'v1', - }); - expect(provider.operations).toBeUndefined(); - }); -}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.ts b/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.ts deleted file mode 100644 index 09f6b6efdbb..00000000000 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/providerFormTypes.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as yup from 'yup'; -import type { Provider } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { createYupValidator } from '../../utils/createYupValidator'; -import { type TFunction, makeTranslator } from '../../utils/formUtils'; - -export type ProviderForm = { - name: string; - endpoint: string; - service_type: string; - schema_version: string; - operations: string[]; -}; - -export const KNOWN_OPERATIONS = [ - 'create', - 'read', - 'update', - 'delete', - 'list', - 'patch', -] as const; - -function buildProviderSchema(t?: TFunction) { - const m = makeTranslator(t); - return yup.object({ - name: yup - .string() - .required(m('validation.provider.nameRequired', 'Name is required')) - .matches( - /^[a-z][a-z0-9-]*$/, - m( - 'validation.provider.namePattern', - 'Only lowercase letters, numbers, and hyphens are allowed (must start with a letter)', - ), - ), - endpoint: yup - .string() - .required( - m('validation.provider.endpointRequired', 'Endpoint is required'), - ) - .matches( - /^https?:\/\/[^\s]+$/, - m( - 'validation.provider.endpointPattern', - 'Must start with http:// or https:// (e.g. http://my-service:8081/api)', - ), - ), - service_type: yup - .string() - .required( - m( - 'validation.provider.serviceTypeRequired', - 'Service type is required', - ), - ) - .min( - 1, - m( - 'validation.provider.serviceTypeMin', - 'Please select a service type from the list', - ), - ), - schema_version: yup - .string() - .required( - m( - 'validation.provider.schemaVersionRequired', - 'Schema version is required', - ), - ) - .matches( - /^v\d+(?:(?:alpha|beta)\d*)?$/, - m( - 'validation.provider.schemaVersionPattern', - 'Must follow the pattern v[alpha|beta][number] \u2014 e.g. v1, v1alpha1, v2beta2', - ), - ), - }); -} - -export function validateProviderForm( - form: ProviderForm, - t?: TFunction, -): Partial> { - const { validate } = createYupValidator(buildProviderSchema(t)); - return validate(form); -} - -export function isProviderFormValid(form: ProviderForm): boolean { - const { isValid } = createYupValidator(buildProviderSchema()); - return isValid(form); -} - -export function emptyProviderForm(): ProviderForm { - return { - name: '', - endpoint: '', - service_type: '', - schema_version: 'v1alpha1', - operations: [], - }; -} - -/** Convert "my-awesome-provider" → "My Awesome Provider" */ -export function nameToDisplayName(name: string): string { - return name - .split('-') - .map(word => (word ? word[0].toUpperCase() + word.slice(1) : '')) - .join(' ') - .trim(); -} - -export function providerToForm(p: Provider): ProviderForm { - return { - name: p.name ?? '', - endpoint: p.endpoint ?? '', - service_type: p.service_type ?? '', - schema_version: p.schema_version ?? 'v1alpha1', - operations: p.operations ?? [], - }; -} - -export function formToProvider(f: ProviderForm): Provider { - return { - name: f.name.trim(), - display_name: nameToDisplayName(f.name.trim()), - endpoint: f.endpoint.trim(), - service_type: f.service_type.trim(), - schema_version: f.schema_version.trim(), - operations: f.operations.length > 0 ? f.operations : undefined, - }; -} diff --git a/workspaces/dcm/plugins/dcm/src/plugin.ts b/workspaces/dcm/plugins/dcm/src/plugin.ts index 720942f09ba..25f23312535 100644 --- a/workspaces/dcm/plugins/dcm/src/plugin.ts +++ b/workspaces/dcm/plugins/dcm/src/plugin.ts @@ -21,15 +21,15 @@ import { fetchApiRef, } from '@backstage/core-plugin-api'; import { + AgentsClient, CatalogClient, PolicyManagerClient, - ProvidersClient, ResourcesClient, } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { rootRouteRef, - providersRouteRef, + agentsRouteRef, policiesRouteRef, serviceTypesRouteRef, catalogItemsRouteRef, @@ -37,9 +37,9 @@ import { resourcesRouteRef, } from './routes'; import { + agentsApiRef, catalogApiRef, policyManagerApiRef, - providersApiRef, resourcesApiRef, } from './apis'; @@ -52,7 +52,7 @@ export const dcmPlugin = createPlugin({ id: 'dcm', routes: { root: rootRouteRef, - providers: providersRouteRef, + agents: agentsRouteRef, policies: policiesRouteRef, serviceTypes: serviceTypesRouteRef, catalogItems: catalogItemsRouteRef, @@ -75,10 +75,10 @@ export const dcmPlugin = createPlugin({ }, }), createApiFactory({ - api: providersApiRef, + api: agentsApiRef, deps: { discoveryApi: discoveryApiRef, fetchApi: fetchApiRef }, factory({ discoveryApi, fetchApi }) { - return new ProvidersClient({ discoveryApi, fetchApi }); + return new AgentsClient({ discoveryApi, fetchApi }); }, }), createApiFactory({ diff --git a/workspaces/dcm/plugins/dcm/src/routes.ts b/workspaces/dcm/plugins/dcm/src/routes.ts index 9697c5d60c8..5ae7e629dc4 100644 --- a/workspaces/dcm/plugins/dcm/src/routes.ts +++ b/workspaces/dcm/plugins/dcm/src/routes.ts @@ -21,10 +21,10 @@ export const rootRouteRef = createRouteRef({ // ── API-aligned tab route refs ───────────────────────────────────────────── -export const providersRouteRef = createSubRouteRef({ - id: 'dcm-providers', +export const agentsRouteRef = createSubRouteRef({ + id: 'dcm-agents', parent: rootRouteRef, - path: '/providers', + path: '/agents', }); export const policiesRouteRef = createSubRouteRef({ diff --git a/workspaces/dcm/plugins/dcm/src/translations/de.ts b/workspaces/dcm/plugins/dcm/src/translations/de.ts index f156c29f64a..b2e4ed1b1ed 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/de.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/de.ts @@ -27,7 +27,7 @@ const dcmTranslationDe: TranslationMessages< ref: dcmTranslationRef, messages: { 'page.title': 'Rechenzentrum', - 'page.tabs.providers': 'Anbieter', + 'page.tabs.agents': 'Agenten', 'page.tabs.policies': 'Richtlinien', 'page.tabs.serviceTypes': 'Diensttypen', 'page.tabs.catalogItems': 'Katalogelemente', @@ -55,47 +55,36 @@ const dcmTranslationDe: TranslationMessages< 'deleteDialog.cancelButton': 'Abbrechen', 'deleteDialog.body': 'M\u00f6chten Sie {{resourceName}} wirklich l\u00f6schen? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.', - 'providers.emptyTitle': 'Keine Anbieter registriert', - 'providers.emptyDescription': - 'Registrieren Sie einen Dienstanbieter, damit DCM Ressourcen auf externer Infrastruktur bereitstellen kann (z.\u00a0B. OpenShift, AWS).', - 'providers.registerButton': 'Registrieren', - 'providers.entityLabel': 'Anbieter', - 'providers.registerDialogTitle': 'Anbieter registrieren', - 'providers.editDialogTitle': 'Anbieter bearbeiten', - 'providers.saveButton': 'Speichern', - 'providers.createSuccess': 'Anbieter erfolgreich registriert.', - 'providers.updateSuccess': 'Anbieter erfolgreich aktualisiert.', - 'providers.deleteSuccess': 'Anbieter erfolgreich gel\u00f6scht.', - 'providers.deleteLabel': 'Anbieter', - 'providers.columns.displayName': 'Anzeigename', - 'providers.columns.name': 'Name', - 'providers.columns.endpoint': 'Endpunkt', - 'providers.columns.serviceType': 'Diensttyp', - 'providers.columns.operations': 'Operationen', - 'providers.columns.status': 'Status', - 'providers.form.nameLabel': 'Name *', - 'providers.form.namePlaceholder': 'z.\u00a0B. mein-k8s-anbieter', - 'providers.form.nameHelper': + 'agents.emptyTitle': 'Keine Agenten registriert', + 'agents.emptyDescription': + 'Umgebungsagenten registrieren sich bei der Steuerungsebene und senden regelmäßige Heartbeats.', + 'agents.registerButton': 'Registrieren', + 'agents.entityLabel': 'Agenten', + 'agents.registerDialogTitle': 'Agent registrieren', + 'agents.createSuccess': 'Agent erfolgreich registriert.', + 'agents.columns.name': 'Name', + 'agents.columns.environment': 'Umgebung', + 'agents.columns.serviceTypes': 'Diensttypen', + 'agents.columns.cost': 'Kosten', + 'agents.columns.topic': 'Topic', + 'agents.columns.health': 'Zustand', + 'agents.form.nameLabel': 'Name *', + 'agents.form.namePlaceholder': 'z.\u00a0B. env-agent-west-1', + 'agents.form.nameHelper': 'Eindeutiger Slug \u2014 nur Kleinbuchstaben, Zahlen und Bindestriche', - 'providers.form.nameHelperEditMode': - 'Der Anbietername kann nach der Erstellung nicht ge\u00e4ndert werden', - 'providers.form.endpointLabel': 'Endpunkt *', - 'providers.form.endpointPlaceholder': 'https://api.beispiel.de', - 'providers.form.endpointHelper': - 'Vollst\u00e4ndige URL der Anbieter-API (z.\u00a0B. https://api.beispiel.de)', - 'providers.form.serviceTypeLabel': 'Diensttyp *', - 'providers.form.serviceTypeEmpty': 'Keine Diensttypen verf\u00fcgbar', - 'providers.form.serviceTypeSelect': 'Diensttyp ausw\u00e4hlen\u2026', - 'providers.form.serviceTypeHelperNoTypes': - 'Erstellen Sie zuerst einen Diensttyp im Reiter Diensttypen', - 'providers.form.serviceTypeHelperDefault': - 'Aus registrierten Diensttypen ausw\u00e4hlen', - 'providers.form.schemaVersionLabel': 'Schema-Version *', - 'providers.form.schemaVersionHelper': - 'z.\u00a0B. v1, v1alpha1, v2beta2 \u2014 nur v[alpha|beta][Zahl]', - 'providers.form.operationsLabel': 'Operationen', - 'providers.form.operationsHelper': - 'Die von diesem Anbieter unterst\u00fctzten Operationen ausw\u00e4hlen', + 'agents.form.environmentLabel': 'Umgebung *', + 'agents.form.environmentPlaceholder': 'z.\u00a0B. production', + 'agents.form.environmentHelper': 'Umgebungsbezeichnung für den Agenten', + 'agents.form.serviceTypesLabel': 'Diensttypen *', + 'agents.form.serviceTypesHelper': + 'Diensttypen, die dieser Agent bereitstellen kann', + 'agents.form.costLabel': 'Kosten *', + 'agents.form.costHelper': + 'Relatives Kostengewicht für Platzierungsentscheidungen', + 'agents.form.topicNameLabel': 'Topic-Name *', + 'agents.form.topicNamePlaceholder': 'z.\u00a0B. dcm.agent.env-agent-west-1', + 'agents.form.topicNameHelper': + 'NATS-Topic-Name \u2014 muss mit dcm.agent. beginnen', 'policies.emptyTitle': 'Keine Richtlinien definiert', 'policies.emptyDescription': 'Erstellen Sie OPA-Rego-Richtlinien, um Governance-Regeln f\u00fcr DCM-Ressourcen durchzusetzen.', @@ -283,19 +272,16 @@ const dcmTranslationDe: TranslationMessages< 'copyButton.copied': 'Kopiert!', 'copyButton.failed': 'Kopieren fehlgeschlagen', 'copyButton.ariaLabel': 'In die Zwischenablage kopieren', - 'validation.provider.nameRequired': 'Name ist erforderlich', - 'validation.provider.namePattern': + 'validation.agent.nameRequired': 'Name ist erforderlich', + 'validation.agent.namePattern': 'Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt (muss mit einem Buchstaben beginnen)', - 'validation.provider.endpointRequired': 'Endpunkt ist erforderlich', - 'validation.provider.endpointPattern': - 'Muss mit http:// oder https:// beginnen (z. B. https://mein-dienst:8081/api)', - 'validation.provider.serviceTypeRequired': 'Diensttyp ist erforderlich', - 'validation.provider.serviceTypeMin': - 'Bitte wählen Sie einen Diensttyp aus der Liste', - 'validation.provider.schemaVersionRequired': - 'Schema-Version ist erforderlich', - 'validation.provider.schemaVersionPattern': - 'Muss dem Muster v[alpha|beta][Zahl] folgen \u2014 z. B. v1, v1alpha1, v2beta2', + 'validation.agent.environmentRequired': 'Umgebung ist erforderlich', + 'validation.agent.serviceTypesRequired': + 'Mindestens ein Diensttyp ist erforderlich', + 'validation.agent.costRequired': 'Kosten sind erforderlich', + 'validation.agent.topicNameRequired': 'Topic-Name ist erforderlich', + 'validation.agent.topicNamePattern': + 'Topic-Name muss mit dcm.agent. beginnen', 'validation.policy.displayNameRequired': 'Anzeigename ist erforderlich', 'validation.policy.displayNameEmpty': 'Anzeigename darf nicht leer sein', 'validation.policy.displayNameMax': diff --git a/workspaces/dcm/plugins/dcm/src/translations/es.ts b/workspaces/dcm/plugins/dcm/src/translations/es.ts index d9a7c23f3e7..c2af37d50b9 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/es.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/es.ts @@ -27,7 +27,7 @@ const dcmTranslationEs: TranslationMessages< ref: dcmTranslationRef, messages: { 'page.title': 'Centro de datos', - 'page.tabs.providers': 'Proveedores', + 'page.tabs.agents': 'Agentes', 'page.tabs.policies': 'Pol\u00edticas', 'page.tabs.serviceTypes': 'Tipos de servicio', 'page.tabs.catalogItems': 'Elementos del cat\u00e1logo', @@ -55,47 +55,37 @@ const dcmTranslationEs: TranslationMessages< 'deleteDialog.cancelButton': 'Cancelar', 'deleteDialog.body': '\u00bfEst\u00e1 seguro de que desea eliminar {{resourceName}}? Esta acci\u00f3n no se puede deshacer.', - 'providers.emptyTitle': 'No hay proveedores registrados', - 'providers.emptyDescription': - 'Registre un proveedor de servicios para que DCM pueda aprovisionar recursos en infraestructura externa (p.\u00a0ej. OpenShift, AWS).', - 'providers.registerButton': 'Registrar', - 'providers.entityLabel': 'Proveedores', - 'providers.registerDialogTitle': 'Registrar proveedor', - 'providers.editDialogTitle': 'Editar proveedor', - 'providers.saveButton': 'Guardar', - 'providers.createSuccess': 'Proveedor registrado correctamente.', - 'providers.updateSuccess': 'Proveedor actualizado correctamente.', - 'providers.deleteSuccess': 'Proveedor eliminado correctamente.', - 'providers.deleteLabel': 'proveedor', - 'providers.columns.displayName': 'Nombre visible', - 'providers.columns.name': 'Nombre', - 'providers.columns.endpoint': 'Punto de conexi\u00f3n', - 'providers.columns.serviceType': 'Tipo de servicio', - 'providers.columns.operations': 'Operaciones', - 'providers.columns.status': 'Estado', - 'providers.form.nameLabel': 'Nombre *', - 'providers.form.namePlaceholder': 'p.\u00a0ej. mi-proveedor-k8s', - 'providers.form.nameHelper': + 'agents.emptyTitle': 'No hay agentes registrados', + 'agents.emptyDescription': + 'Los agentes de entorno se registran en el plano de control y env\u00edan latidos peri\u00f3dicos.', + 'agents.registerButton': 'Registrar', + 'agents.entityLabel': 'Agentes', + 'agents.registerDialogTitle': 'Registrar agente', + 'agents.createSuccess': 'Agente registrado correctamente.', + 'agents.columns.name': 'Nombre', + 'agents.columns.environment': 'Entorno', + 'agents.columns.serviceTypes': 'Tipos de servicio', + 'agents.columns.cost': 'Coste', + 'agents.columns.topic': 'Topic', + 'agents.columns.health': 'Estado', + 'agents.form.nameLabel': 'Nombre *', + 'agents.form.namePlaceholder': 'p.\u00a0ej. env-agent-west-1', + 'agents.form.nameHelper': 'Identificador \u00fanico \u2014 solo letras min\u00fasculas, n\u00fameros y guiones', - 'providers.form.nameHelperEditMode': - 'El nombre del proveedor no puede cambiarse tras su creaci\u00f3n', - 'providers.form.endpointLabel': 'Punto de conexi\u00f3n *', - 'providers.form.endpointPlaceholder': 'https://api.ejemplo.com', - 'providers.form.endpointHelper': - 'URL completa de la API del proveedor (p.\u00a0ej. https://api.ejemplo.com)', - 'providers.form.serviceTypeLabel': 'Tipo de servicio *', - 'providers.form.serviceTypeEmpty': 'No hay tipos de servicio disponibles', - 'providers.form.serviceTypeSelect': 'Seleccione un tipo de servicio\u2026', - 'providers.form.serviceTypeHelperNoTypes': - 'Cree primero un tipo de servicio en la pesta\u00f1a Tipos de servicio', - 'providers.form.serviceTypeHelperDefault': - 'Seleccionar de los tipos de servicio registrados', - 'providers.form.schemaVersionLabel': 'Versi\u00f3n de esquema *', - 'providers.form.schemaVersionHelper': - 'p.\u00a0ej. v1, v1alpha1, v2beta2 \u2014 solo v[alpha|beta][n\u00famero]', - 'providers.form.operationsLabel': 'Operaciones', - 'providers.form.operationsHelper': - 'Seleccionar las operaciones admitidas por este proveedor', + 'agents.form.environmentLabel': 'Entorno *', + 'agents.form.environmentPlaceholder': 'p.\u00a0ej. production', + 'agents.form.environmentHelper': 'Etiqueta de entorno del agente', + 'agents.form.serviceTypesLabel': 'Tipos de servicio *', + 'agents.form.serviceTypesHelper': + 'Tipos de servicio que este agente puede proveer', + 'agents.form.costLabel': 'Coste *', + 'agents.form.costHelper': + 'Peso de coste relativo para decisiones de ubicaci\u00f3n', + 'agents.form.topicNameLabel': 'Nombre de topic *', + 'agents.form.topicNamePlaceholder': + 'p.\u00a0ej. dcm.agent.env-agent-west-1', + 'agents.form.topicNameHelper': + 'Nombre del topic NATS \u2014 debe comenzar con dcm.agent.', 'policies.emptyTitle': 'No hay pol\u00edticas definidas', 'policies.emptyDescription': 'Cree pol\u00edticas OPA Rego para aplicar reglas de gobernanza en los recursos DCM.', @@ -286,21 +276,16 @@ const dcmTranslationEs: TranslationMessages< 'copyButton.copied': '\u00a1Copiado!', 'copyButton.failed': 'Error al copiar', 'copyButton.ariaLabel': 'Copiar al portapapeles', - 'validation.provider.nameRequired': 'El nombre es obligatorio', - 'validation.provider.namePattern': + 'validation.agent.nameRequired': 'El nombre es obligatorio', + 'validation.agent.namePattern': 'Solo se permiten letras min\u00fasculas, n\u00fameros y guiones (debe comenzar con una letra)', - 'validation.provider.endpointRequired': - 'El punto de conexi\u00f3n es obligatorio', - 'validation.provider.endpointPattern': - 'Debe comenzar con http:// o https:// (p. ej. https://mi-servicio:8081/api)', - 'validation.provider.serviceTypeRequired': - 'El tipo de servicio es obligatorio', - 'validation.provider.serviceTypeMin': - 'Seleccione un tipo de servicio de la lista', - 'validation.provider.schemaVersionRequired': - 'La versi\u00f3n del esquema es obligatoria', - 'validation.provider.schemaVersionPattern': - 'Debe seguir el patr\u00f3n v[alpha|beta][n\u00famero] \u2014 p. ej. v1, v1alpha1, v2beta2', + 'validation.agent.environmentRequired': 'El entorno es obligatorio', + 'validation.agent.serviceTypesRequired': + 'Se requiere al menos un tipo de servicio', + 'validation.agent.costRequired': 'El coste es obligatorio', + 'validation.agent.topicNameRequired': 'El nombre de topic es obligatorio', + 'validation.agent.topicNamePattern': + 'El nombre del topic debe comenzar con dcm.agent.', 'validation.policy.displayNameRequired': 'El nombre para mostrar es obligatorio', 'validation.policy.displayNameEmpty': diff --git a/workspaces/dcm/plugins/dcm/src/translations/fr.ts b/workspaces/dcm/plugins/dcm/src/translations/fr.ts index bee69081645..1d3add66a3d 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/fr.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/fr.ts @@ -27,7 +27,7 @@ const dcmTranslationFr: TranslationMessages< ref: dcmTranslationRef, messages: { 'page.title': 'Centre de donn\u00e9es', - 'page.tabs.providers': 'Fournisseurs', + 'page.tabs.agents': 'Agents', 'page.tabs.policies': 'Politiques', 'page.tabs.serviceTypes': 'Types de service', 'page.tabs.catalogItems': '\u00c9l\u00e9ments du catalogue', @@ -55,48 +55,38 @@ const dcmTranslationFr: TranslationMessages< 'deleteDialog.cancelButton': 'Annuler', 'deleteDialog.body': '\u00cates-vous s\u00fbr de vouloir supprimer {{resourceName}}\u00a0? Cette action est irr\u00e9versible.', - 'providers.emptyTitle': 'Aucun fournisseur enregistr\u00e9', - 'providers.emptyDescription': - 'Enregistrez un fournisseur de services pour permettre \u00e0 DCM de provisionner des ressources sur une infrastructure externe (p.\u00a0ex. OpenShift, AWS).', - 'providers.registerButton': 'Enregistrer', - 'providers.entityLabel': 'Fournisseurs', - 'providers.registerDialogTitle': 'Enregistrer un fournisseur', - 'providers.editDialogTitle': 'Modifier le fournisseur', - 'providers.saveButton': 'Enregistrer', - 'providers.createSuccess': 'Fournisseur enregistr\u00e9 avec succ\u00e8s.', - 'providers.updateSuccess': 'Fournisseur mis \u00e0 jour avec succ\u00e8s.', - 'providers.deleteSuccess': 'Fournisseur supprim\u00e9 avec succ\u00e8s.', - 'providers.deleteLabel': 'fournisseur', - 'providers.columns.displayName': 'Nom affich\u00e9', - 'providers.columns.name': 'Nom', - 'providers.columns.endpoint': 'Point de terminaison', - 'providers.columns.serviceType': 'Type de service', - 'providers.columns.operations': 'Op\u00e9rations', - 'providers.columns.status': '\u00c9tat', - 'providers.form.nameLabel': 'Nom *', - 'providers.form.namePlaceholder': 'p.\u00a0ex. mon-fournisseur-k8s', - 'providers.form.nameHelper': + 'agents.emptyTitle': 'Aucun agent enregistr\u00e9', + 'agents.emptyDescription': + 'Les agents d\u2019environnement s\u2019enregistrent aupr\u00e8s du plan de contr\u00f4le et envoient des battements de c\u0153ur p\u00e9riodiques.', + 'agents.registerButton': 'Enregistrer', + 'agents.entityLabel': 'Agents', + 'agents.registerDialogTitle': 'Enregistrer un agent', + 'agents.createSuccess': 'Agent enregistr\u00e9 avec succ\u00e8s.', + 'agents.columns.name': 'Nom', + 'agents.columns.environment': 'Environnement', + 'agents.columns.serviceTypes': 'Types de service', + 'agents.columns.cost': 'Co\u00fbt', + 'agents.columns.topic': 'Topic', + 'agents.columns.health': '\u00c9tat', + 'agents.form.nameLabel': 'Nom *', + 'agents.form.namePlaceholder': 'p.\u00a0ex. env-agent-west-1', + 'agents.form.nameHelper': 'Identifiant unique \u2014 uniquement lettres minuscules, chiffres et tirets', - 'providers.form.nameHelperEditMode': - 'Le nom du fournisseur ne peut pas \u00eatre modifi\u00e9 apr\u00e8s la cr\u00e9ation', - 'providers.form.endpointLabel': 'Point de terminaison *', - 'providers.form.endpointPlaceholder': 'https://api.exemple.com', - 'providers.form.endpointHelper': - 'URL compl\u00e8te de l\u2019API du fournisseur (p.\u00a0ex. https://api.exemple.com)', - 'providers.form.serviceTypeLabel': 'Type de service *', - 'providers.form.serviceTypeEmpty': 'Aucun type de service disponible', - 'providers.form.serviceTypeSelect': - 'S\u00e9lectionnez un type de service\u2026', - 'providers.form.serviceTypeHelperNoTypes': - 'Cr\u00e9ez d\u2019abord un type de service dans l\u2019onglet Types de service', - 'providers.form.serviceTypeHelperDefault': - 'S\u00e9lectionner parmi les types de service enregistr\u00e9s', - 'providers.form.schemaVersionLabel': 'Version du sch\u00e9ma *', - 'providers.form.schemaVersionHelper': - 'p.\u00a0ex. v1, v1alpha1, v2beta2 \u2014 uniquement v[alpha|beta][nombre]', - 'providers.form.operationsLabel': 'Op\u00e9rations', - 'providers.form.operationsHelper': - 'S\u00e9lectionner les op\u00e9rations prises en charge par ce fournisseur', + 'agents.form.environmentLabel': 'Environnement *', + 'agents.form.environmentPlaceholder': 'p.\u00a0ex. production', + 'agents.form.environmentHelper': + '\u00c9tiquette d\u2019environnement de l\u2019agent', + 'agents.form.serviceTypesLabel': 'Types de service *', + 'agents.form.serviceTypesHelper': + 'Types de service que cet agent peut fournir', + 'agents.form.costLabel': 'Co\u00fbt *', + 'agents.form.costHelper': + 'Poids de co\u00fbt relatif pour les d\u00e9cisions de placement', + 'agents.form.topicNameLabel': 'Nom du topic *', + 'agents.form.topicNamePlaceholder': + 'p.\u00a0ex. dcm.agent.env-agent-west-1', + 'agents.form.topicNameHelper': + 'Nom du topic NATS \u2014 doit commencer par dcm.agent.', 'policies.emptyTitle': 'Aucune politique d\u00e9finie', 'policies.emptyDescription': 'Cr\u00e9ez des politiques OPA Rego pour appliquer des r\u00e8gles de gouvernance sur les ressources DCM.', @@ -292,21 +282,17 @@ const dcmTranslationFr: TranslationMessages< 'copyButton.copied': 'Copi\u00e9\u00a0!', 'copyButton.failed': '\u00c9chec de la copie', 'copyButton.ariaLabel': 'Copier dans le presse-papiers', - 'validation.provider.nameRequired': 'Le nom est obligatoire', - 'validation.provider.namePattern': + 'validation.agent.nameRequired': 'Le nom est obligatoire', + 'validation.agent.namePattern': 'Seules les lettres minuscules, les chiffres et les tirets sont autoris\u00e9s (doit commencer par une lettre)', - 'validation.provider.endpointRequired': - 'Le point de terminaison est obligatoire', - 'validation.provider.endpointPattern': - 'Doit commencer par http:// ou https:// (p. ex. https://mon-service:8081/api)', - 'validation.provider.serviceTypeRequired': - 'Le type de service est obligatoire', - 'validation.provider.serviceTypeMin': - 'Veuillez s\u00e9lectionner un type de service dans la liste', - 'validation.provider.schemaVersionRequired': - 'La version du sch\u00e9ma est obligatoire', - 'validation.provider.schemaVersionPattern': - 'Doit suivre le format v[alpha|beta][nombre] \u2014 ex. v1, v1alpha1, v2beta2', + 'validation.agent.environmentRequired': + 'L\u2019environnement est obligatoire', + 'validation.agent.serviceTypesRequired': + 'Au moins un type de service est requis', + 'validation.agent.costRequired': 'Le co\u00fbt est obligatoire', + 'validation.agent.topicNameRequired': 'Le nom du topic est obligatoire', + 'validation.agent.topicNamePattern': + 'Le nom du topic doit commencer par dcm.agent.', 'validation.policy.displayNameRequired': "Le nom d'affichage est obligatoire", 'validation.policy.displayNameEmpty': diff --git a/workspaces/dcm/plugins/dcm/src/translations/it.ts b/workspaces/dcm/plugins/dcm/src/translations/it.ts index 3edc0378343..049fe7cbf92 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/it.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/it.ts @@ -27,7 +27,7 @@ const dcmTranslationIt: TranslationMessages< ref: dcmTranslationRef, messages: { 'page.title': 'Centro dati', - 'page.tabs.providers': 'Provider', + 'page.tabs.agents': 'Agenti', 'page.tabs.policies': 'Criteri', 'page.tabs.serviceTypes': 'Tipi di servizio', 'page.tabs.catalogItems': 'Elementi del catalogo', @@ -55,47 +55,36 @@ const dcmTranslationIt: TranslationMessages< 'deleteDialog.cancelButton': 'Annulla', 'deleteDialog.body': 'Eliminare {{resourceName}}? Questa azione non pu\u00f2 essere annullata.', - 'providers.emptyTitle': 'Nessun provider registrato', - 'providers.emptyDescription': - 'Registra un provider di servizi per consentire a DCM di effettuare il provisioning di risorse su infrastrutture esterne (p.\u00a0es. OpenShift, AWS).', - 'providers.registerButton': 'Registra', - 'providers.entityLabel': 'Provider', - 'providers.registerDialogTitle': 'Registra provider', - 'providers.editDialogTitle': 'Modifica provider', - 'providers.saveButton': 'Salva', - 'providers.createSuccess': 'Provider registrato correttamente.', - 'providers.updateSuccess': 'Provider aggiornato correttamente.', - 'providers.deleteSuccess': 'Provider eliminato correttamente.', - 'providers.deleteLabel': 'provider', - 'providers.columns.displayName': 'Nome visualizzato', - 'providers.columns.name': 'Nome', - 'providers.columns.endpoint': 'Endpoint', - 'providers.columns.serviceType': 'Tipo di servizio', - 'providers.columns.operations': 'Operazioni', - 'providers.columns.status': 'Stato', - 'providers.form.nameLabel': 'Nome *', - 'providers.form.namePlaceholder': 'es. mio-provider-k8s', - 'providers.form.nameHelper': + 'agents.emptyTitle': 'Nessun agente registrato', + 'agents.emptyDescription': + 'Gli agenti di ambiente si registrano nel piano di controllo e inviano heartbeat periodici.', + 'agents.registerButton': 'Registra', + 'agents.entityLabel': 'Agenti', + 'agents.registerDialogTitle': 'Registra agente', + 'agents.createSuccess': 'Agente registrato correttamente.', + 'agents.columns.name': 'Nome', + 'agents.columns.environment': 'Ambiente', + 'agents.columns.serviceTypes': 'Tipi di servizio', + 'agents.columns.cost': 'Costo', + 'agents.columns.topic': 'Topic', + 'agents.columns.health': 'Stato', + 'agents.form.nameLabel': 'Nome *', + 'agents.form.namePlaceholder': 'es. env-agent-west-1', + 'agents.form.nameHelper': 'Identificatore univoco \u2014 solo lettere minuscole, numeri e trattini', - 'providers.form.nameHelperEditMode': - 'Il nome del provider non pu\u00f2 essere modificato dopo la creazione', - 'providers.form.endpointLabel': 'Endpoint *', - 'providers.form.endpointPlaceholder': 'https://api.esempio.com', - 'providers.form.endpointHelper': - 'URL completo dell\u2019API del provider (p.\u00a0es. https://api.esempio.com)', - 'providers.form.serviceTypeLabel': 'Tipo di servizio *', - 'providers.form.serviceTypeEmpty': 'Nessun tipo di servizio disponibile', - 'providers.form.serviceTypeSelect': 'Seleziona un tipo di servizio\u2026', - 'providers.form.serviceTypeHelperNoTypes': - 'Crea prima un tipo di servizio nella scheda Tipi di servizio', - 'providers.form.serviceTypeHelperDefault': - 'Seleziona dai tipi di servizio registrati', - 'providers.form.schemaVersionLabel': 'Versione schema *', - 'providers.form.schemaVersionHelper': - 'p.\u00a0es. v1, v1alpha1, v2beta2 \u2014 solo v[alpha|beta][numero]', - 'providers.form.operationsLabel': 'Operazioni', - 'providers.form.operationsHelper': - 'Seleziona le operazioni supportate da questo provider', + 'agents.form.environmentLabel': 'Ambiente *', + 'agents.form.environmentPlaceholder': 'es. production', + 'agents.form.environmentHelper': "Etichetta ambiente dell'agente", + 'agents.form.serviceTypesLabel': 'Tipi di servizio *', + 'agents.form.serviceTypesHelper': + 'Tipi di servizio che questo agente pu\u00f2 fornire', + 'agents.form.costLabel': 'Costo *', + 'agents.form.costHelper': + 'Peso di costo relativo per le decisioni di posizionamento', + 'agents.form.topicNameLabel': 'Nome topic *', + 'agents.form.topicNamePlaceholder': 'es. dcm.agent.env-agent-west-1', + 'agents.form.topicNameHelper': + 'Nome del topic NATS \u2014 deve iniziare con dcm.agent.', 'policies.emptyTitle': 'Nessun criterio definito', 'policies.emptyDescription': 'Crea criteri OPA Rego per applicare regole di governance alle risorse DCM.', @@ -285,20 +274,17 @@ const dcmTranslationIt: TranslationMessages< 'copyButton.copied': 'Copiato!', 'copyButton.failed': 'Copia non riuscita', 'copyButton.ariaLabel': 'Copia negli appunti', - 'validation.provider.nameRequired': 'Il nome \u00e8 obbligatorio', - 'validation.provider.namePattern': + 'validation.agent.nameRequired': 'Il nome \u00e8 obbligatorio', + 'validation.agent.namePattern': 'Sono consentiti solo lettere minuscole, numeri e trattini (deve iniziare con una lettera)', - 'validation.provider.endpointRequired': "L'endpoint \u00e8 obbligatorio", - 'validation.provider.endpointPattern': - 'Deve iniziare con http:// o https:// (es. https://mio-servizio:8081/api)', - 'validation.provider.serviceTypeRequired': - 'Il tipo di servizio \u00e8 obbligatorio', - 'validation.provider.serviceTypeMin': - 'Selezionare un tipo di servizio dalla lista', - 'validation.provider.schemaVersionRequired': - 'La versione dello schema \u00e8 obbligatoria', - 'validation.provider.schemaVersionPattern': - 'Deve seguire il formato v[alpha|beta][numero] \u2014 es. v1, v1alpha1, v2beta2', + 'validation.agent.environmentRequired': "L'ambiente \u00e8 obbligatorio", + 'validation.agent.serviceTypesRequired': + '\u00c8 richiesto almeno un tipo di servizio', + 'validation.agent.costRequired': 'Il costo \u00e8 obbligatorio', + 'validation.agent.topicNameRequired': + 'Il nome del topic \u00e8 obbligatorio', + 'validation.agent.topicNamePattern': + 'Il nome del topic deve iniziare con dcm.agent.', 'validation.policy.displayNameRequired': 'Il nome visualizzato \u00e8 obbligatorio', 'validation.policy.displayNameEmpty': diff --git a/workspaces/dcm/plugins/dcm/src/translations/ja.ts b/workspaces/dcm/plugins/dcm/src/translations/ja.ts index b9678a5abcd..fe07e6dce0f 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ja.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ja.ts @@ -20,8 +20,6 @@ import { } from '@backstage/core-plugin-api/alpha'; import { dcmTranslationRef } from './ref'; -const protocol = 'http'; - const dcmTranslationJa: TranslationMessages< 'plugin.dcm', Record @@ -29,7 +27,7 @@ const dcmTranslationJa: TranslationMessages< ref: dcmTranslationRef, messages: { 'page.title': '\u30c7\u30fc\u30bf\u30bb\u30f3\u30bf\u30fc', - 'page.tabs.providers': '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc', + 'page.tabs.agents': '\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8', 'page.tabs.policies': '\u30dd\u30ea\u30b7\u30fc', 'page.tabs.serviceTypes': '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7', 'page.tabs.catalogItems': @@ -58,60 +56,41 @@ const dcmTranslationJa: TranslationMessages< 'deleteDialog.cancelButton': '\u30ad\u30e3\u30f3\u30bb\u30eb', 'deleteDialog.body': '{{resourceName}}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f\u3053\u306e\u64cd\u4f5c\u306f\u5143\u306b\u623b\u305b\u307e\u305b\u3093\u3002', - 'providers.emptyTitle': - '\u767b\u9332\u6e08\u307f\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u306a\u3057', - 'providers.emptyDescription': - 'DCM\u304c\u5916\u90e8\u30a4\u30f3\u30d5\u30e9\u30b9\u30c8\u30e9\u30af\u30c1\u30e3\u3067\u30ea\u30bd\u30fc\u30b9\u3092\u30d7\u30ed\u30d3\u30b8\u30e7\u30cb\u30f3\u30b0\u3067\u304d\u308b\u3088\u3046\u306b\u30b5\u30fc\u30d3\u30b9\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u767b\u9332\u3057\u3066\u304f\u3060\u3055\u3044\u3002', - 'providers.registerButton': '\u767b\u9332', - 'providers.entityLabel': '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc', - 'providers.registerDialogTitle': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u767b\u9332', - 'providers.editDialogTitle': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u7de8\u96c6', - 'providers.saveButton': '\u4fdd\u5b58', - 'providers.createSuccess': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u6b63\u5e38\u306b\u767b\u9332\u3057\u307e\u3057\u305f\u3002', - 'providers.updateSuccess': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u6b63\u5e38\u306b\u66f4\u65b0\u3057\u307e\u3057\u305f\u3002', - 'providers.deleteSuccess': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u3092\u6b63\u5e38\u306b\u524a\u9664\u3057\u307e\u3057\u305f\u3002', - 'providers.deleteLabel': '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc', - 'providers.columns.displayName': '\u8868\u793a\u540d', - 'providers.columns.name': '\u540d\u524d', - 'providers.columns.endpoint': '\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8', - 'providers.columns.serviceType': - '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7', - 'providers.columns.operations': - '\u30aa\u30da\u30ec\u30fc\u30b7\u30e7\u30f3', - 'providers.columns.status': '\u30b9\u30c6\u30fc\u30bf\u30b9', - 'providers.form.nameLabel': '\u540d\u524d *', - 'providers.form.namePlaceholder': '\u4f8b: my-k8s-provider', - 'providers.form.nameHelper': - '\u4e00\u610f\u306e\u30b9\u30e9\u30b0\u2014\u5c0f\u6587\u5b57\u3001\u6570\u5b57\u3001\u30cf\u30a4\u30d5\u30f3\u306e\u307f\u4f7f\u7528\u53ef\u80fd', - 'providers.form.nameHelperEditMode': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u540d\u306f\u4f5c\u6210\u5f8c\u306b\u5909\u66f4\u3067\u304d\u307e\u305b\u3093', - 'providers.form.endpointLabel': - '\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8 *', - 'providers.form.endpointPlaceholder': 'https://api.example.com', - 'providers.form.endpointHelper': - '\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc API \u306e\u5b8c\u5168\u306a URL', - 'providers.form.serviceTypeLabel': + 'agents.emptyTitle': + '\u767b\u9332\u6e08\u307f\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u306a\u3057', + 'agents.emptyDescription': + '\u74b0\u5883\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u306f\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d7\u30ec\u30fc\u30f3\u306b\u767b\u9332\u3057\u3001\u5b9a\u671f\u7684\u306b\u30cf\u30fc\u30c8\u30d3\u30fc\u30c8\u3092\u9001\u4fe1\u3057\u307e\u3059\u3002', + 'agents.registerButton': '\u767b\u9332', + 'agents.entityLabel': '\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8', + 'agents.registerDialogTitle': + '\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u3092\u767b\u9332', + 'agents.createSuccess': + '\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u3092\u6b63\u5e38\u306b\u767b\u9332\u3057\u307e\u3057\u305f\u3002', + 'agents.columns.name': '\u540d\u524d', + 'agents.columns.environment': '\u74b0\u5883', + 'agents.columns.serviceTypes': '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7', + 'agents.columns.cost': '\u30b3\u30b9\u30c8', + 'agents.columns.topic': 'Topic', + 'agents.columns.health': '\u30d8\u30eb\u30b9', + 'agents.form.nameLabel': '\u540d\u524d *', + 'agents.form.namePlaceholder': '\u4f8b: env-agent-west-1', + 'agents.form.nameHelper': + '\u4e00\u610f\u306e\u30b9\u30e9\u30b0\u2014\u5c0f\u6587\u5b57\u3001\u6570\u5b57\u3001\u30cf\u30a4\u30d5\u30f3\u306e\u307f', + 'agents.form.environmentLabel': '\u74b0\u5883 *', + 'agents.form.environmentPlaceholder': '\u4f8b: production', + 'agents.form.environmentHelper': + '\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u306e\u74b0\u5883\u30e9\u30d9\u30eb', + 'agents.form.serviceTypesLabel': '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7 *', - 'providers.form.serviceTypeEmpty': - '\u5229\u7528\u53ef\u80fd\u306a\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u306a\u3057', - 'providers.form.serviceTypeSelect': - '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u3092\u9078\u629e\u2026', - 'providers.form.serviceTypeHelperNoTypes': - '\u307e\u305a\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u30bf\u30d6\u3067\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u3092\u4f5c\u6210\u3057\u3066\u304f\u3060\u3055\u3044', - 'providers.form.serviceTypeHelperDefault': - '\u767b\u9332\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u304b\u3089\u9078\u629e', - 'providers.form.schemaVersionLabel': - '\u30b9\u30ad\u30fc\u30de\u30d0\u30fc\u30b8\u30e7\u30f3 *', - 'providers.form.schemaVersionHelper': '\u4f8b: v1, v1alpha1, v2beta2', - 'providers.form.operationsLabel': - '\u30aa\u30da\u30ec\u30fc\u30b7\u30e7\u30f3', - 'providers.form.operationsHelper': - '\u3053\u306e\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc\u304c\u30b5\u30dd\u30fc\u30c8\u3059\u308b\u30aa\u30da\u30ec\u30fc\u30b7\u30e7\u30f3\u3092\u9078\u629e', + 'agents.form.serviceTypesHelper': + '\u3053\u306e\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u304c\u63d0\u4f9b\u3067\u304d\u308b\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7', + 'agents.form.costLabel': '\u30b3\u30b9\u30c8 *', + 'agents.form.costHelper': + '\u30d7\u30ec\u30fc\u30b9\u30e1\u30f3\u30c8\u306e\u76f8\u5bfe\u30b3\u30b9\u30c8\u91cd\u307f', + 'agents.form.topicNameLabel': 'Topic\u540d *', + 'agents.form.topicNamePlaceholder': '\u4f8b: dcm.agent.env-agent-west-1', + 'agents.form.topicNameHelper': + 'NATS Topic\u540d\u2014dcm.agent.\u3067\u59cb\u307e\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059', 'policies.emptyTitle': '\u30dd\u30ea\u30b7\u30fc\u306a\u3057', 'policies.emptyDescription': 'DCM \u30ea\u30bd\u30fc\u30b9\u306b\u5bfe\u3059\u308b\u30ac\u30d0\u30ca\u30f3\u30b9\u30eb\u30fc\u30eb\u3092\u5f37\u5236\u3059\u308b OPA Rego \u30dd\u30ea\u30b7\u30fc\u3092\u4f5c\u6210\u3057\u3066\u304f\u3060\u3055\u3044\u3002', @@ -342,21 +321,20 @@ const dcmTranslationJa: TranslationMessages< 'copyButton.failed': '\u30b3\u30d4\u30fc\u5931\u6557', 'copyButton.ariaLabel': '\u30af\u30ea\u30c3\u30d7\u30dc\u30fc\u30c9\u306b\u30b3\u30d4\u30fc', - 'validation.provider.nameRequired': + 'validation.agent.nameRequired': '\u540d\u524d\u306f\u5fc5\u9808\u3067\u3059', - 'validation.provider.namePattern': + 'validation.agent.namePattern': '\u5c0f\u6587\u5b57\u3001\u6570\u5b57\u3001\u30cf\u30a4\u30d5\u30f3\u306e\u307f\u4f7f\u7528\u3067\u304d\u307e\u3059\uff08\u6587\u5b57\u3067\u59cb\u307e\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\uff09', - 'validation.provider.endpointRequired': - '\u30a8\u30f3\u30c9\u30dd\u30a4\u30f3\u30c8\u306f\u5fc5\u9808\u3067\u3059', - 'validation.provider.endpointPattern': `${protocol}:// \u307e\u305f\u306f https:// \u3067\u59cb\u307e\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\uff08\u4f8b: https://my-service:8081/api\uff09`, - 'validation.provider.serviceTypeRequired': - '\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u306f\u5fc5\u9808\u3067\u3059', - 'validation.provider.serviceTypeMin': - '\u30ea\u30b9\u30c8\u304b\u3089\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044', - 'validation.provider.schemaVersionRequired': - '\u30b9\u30ad\u30fc\u30de\u30d0\u30fc\u30b8\u30e7\u30f3\u306f\u5fc5\u9808\u3067\u3059', - 'validation.provider.schemaVersionPattern': - 'v<\u6570\u5b57>[alpha|beta][<\u6570\u5b57>] \u306e\u5f62\u5f0f\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044 \u2014 \u4f8b: v1, v1alpha1, v2beta2', + 'validation.agent.environmentRequired': + '\u74b0\u5883\u306f\u5fc5\u9808\u3067\u3059', + 'validation.agent.serviceTypesRequired': + '\u5c11\u306a\u304f\u3068\u30821\u3064\u306e\u30b5\u30fc\u30d3\u30b9\u30bf\u30a4\u30d7\u304c\u5fc5\u8981\u3067\u3059', + 'validation.agent.costRequired': + '\u30b3\u30b9\u30c8\u306f\u5fc5\u9808\u3067\u3059', + 'validation.agent.topicNameRequired': + 'Topic\u540d\u306f\u5fc5\u9808\u3067\u3059', + 'validation.agent.topicNamePattern': + 'Topic\u540d\u306fdcm.agent.\u3067\u59cb\u307e\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059', 'validation.policy.displayNameRequired': '\u8868\u793a\u540d\u306f\u5fc5\u9808\u3067\u3059', 'validation.policy.displayNameEmpty': diff --git a/workspaces/dcm/plugins/dcm/src/translations/ref.ts b/workspaces/dcm/plugins/dcm/src/translations/ref.ts index 872c90441df..2f16a1792d4 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ref.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ref.ts @@ -28,7 +28,7 @@ export const dcmMessages = { page: { title: 'Data Center', tabs: { - providers: 'Providers', + agents: 'Agents', policies: 'Policies', serviceTypes: 'Service types', catalogItems: 'Catalog items', @@ -61,48 +61,37 @@ export const dcmMessages = { cancelButton: 'Cancel', body: 'Are you sure you want to delete {{resourceName}}? This action cannot be undone.', }, - providers: { - emptyTitle: 'No providers registered', + agents: { + emptyTitle: 'No agents registered', emptyDescription: - 'Register a service provider to allow DCM to provision resources on external infrastructure (e.g. OpenShift, AWS).', + 'Environment agents register with the control plane and send periodic heartbeats. Register an agent to allow DCM to manage workloads on external environments.', registerButton: 'Register', - entityLabel: 'Providers', - registerDialogTitle: 'Register provider', - editDialogTitle: 'Edit provider', - saveButton: 'Save', - createSuccess: 'Provider registered successfully.', - updateSuccess: 'Provider updated successfully.', - deleteSuccess: 'Provider deleted successfully.', - deleteLabel: 'provider', + entityLabel: 'Agents', + registerDialogTitle: 'Register agent', + createSuccess: 'Agent registered successfully.', columns: { - displayName: 'Display name', name: 'Name', - endpoint: 'Endpoint', - serviceType: 'Service type', - operations: 'Operations', - status: 'Status', + environment: 'Environment', + serviceTypes: 'Service types', + cost: 'Cost', + topic: 'Topic', + health: 'Health', }, form: { nameLabel: 'Name *', - namePlaceholder: 'e.g. my-k8s-provider', + namePlaceholder: 'e.g. env-agent-west-1', nameHelper: 'Unique slug identifier \u2014 only lowercase letters, numbers, and hyphens', - nameHelperEditMode: 'Provider name cannot be changed after creation', - endpointLabel: 'Endpoint *', - endpointPlaceholder: 'https://api.example.com', - endpointHelper: - 'Full URL of the provider API (e.g. https://api.example.com)', - serviceTypeLabel: 'Service type *', - serviceTypeEmpty: 'No service types available', - serviceTypeSelect: 'Select a service type\u2026', - serviceTypeHelperNoTypes: - 'Create a service type first in the Service types tab', - serviceTypeHelperDefault: 'Select from registered service types', - schemaVersionLabel: 'Schema version *', - schemaVersionHelper: - 'e.g. v1, v1alpha1, v2beta2 \u2014 only v[alpha|beta][number]', - operationsLabel: 'Operations', - operationsHelper: 'Select the operations this provider supports', + environmentLabel: 'Environment *', + environmentPlaceholder: 'e.g. production', + environmentHelper: 'Environment label for the agent', + serviceTypesLabel: 'Service types *', + serviceTypesHelper: 'Service types this agent can provide', + costLabel: 'Cost *', + costHelper: 'Relative cost weight used for placement decisions', + topicNameLabel: 'Topic name *', + topicNamePlaceholder: 'e.g. dcm.agent.env-agent-west-1', + topicNameHelper: 'NATS topic name \u2014 must start with dcm.agent.', }, }, policies: { @@ -313,18 +302,15 @@ export const dcmMessages = { ariaLabel: 'Copy to clipboard', }, validation: { - provider: { + agent: { nameRequired: 'Name is required', namePattern: 'Only lowercase letters, numbers, and hyphens are allowed (must start with a letter)', - endpointRequired: 'Endpoint is required', - endpointPattern: - 'Must start with http:// or https:// (e.g. https://my-service:8081/api)', - serviceTypeRequired: 'Service type is required', - serviceTypeMin: 'Please select a service type from the list', - schemaVersionRequired: 'Schema version is required', - schemaVersionPattern: - 'Must follow the pattern v[alpha|beta][number] \u2014 e.g. v1, v1alpha1, v2beta2', + environmentRequired: 'Environment is required', + serviceTypesRequired: 'At least one service type is required', + costRequired: 'Cost is required', + topicNameRequired: 'Topic name is required', + topicNamePattern: 'Topic name must start with dcm.agent.', }, policy: { displayNameRequired: 'Display name is required', From b79f1691b6dec9b9e96fa9a76da5b4cba32a23dc Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Tue, 25 Aug 2026 13:24:54 +0300 Subject: [PATCH 2/2] Address Feedback --- .../replace-providers-with-agents.md | 24 ++- .../dcm/plugins/dcm-common/report.api.md | 177 +++++++++-------- .../dcm-common/src/clients/AgentsApi.ts | 3 +- .../dcm-common/src/clients/AgentsClient.ts | 3 +- workspaces/dcm/plugins/dcm/report.api.md | 10 +- .../dcm/src/components/DcmCrudTabLayout.tsx | 24 ++- .../dcm/src/hooks/usePaginatedCrudTab.test.ts | 40 +++- .../dcm/src/hooks/usePaginatedCrudTab.ts | 33 ++-- .../pages/agents/AgentsTabContent.test.tsx | 77 +++++++- .../dcm/src/pages/agents/AgentsTabContent.tsx | 134 ++++++++++++- .../src/pages/agents/agentFormTypes.test.ts | 9 + .../dcm/src/pages/agents/agentFormTypes.ts | 5 + .../components/AgentFormFields.test.tsx | 179 ++++++++++++++++++ .../agents/components/AgentFormFields.tsx | 82 +++++++- .../agents/components/AgentHealthStatus.tsx | 5 +- .../agents/components/CopyButton.test.tsx | 91 +++++++++ .../pages/agents/components/CopyButton.tsx | 75 ++++++-- .../pages/resources/ResourcesTabContent.tsx | 3 + .../dcm/plugins/dcm/src/translations/de.ts | 6 + .../dcm/plugins/dcm/src/translations/es.ts | 6 + .../dcm/plugins/dcm/src/translations/fr.ts | 6 + .../dcm/plugins/dcm/src/translations/it.ts | 6 + .../dcm/plugins/dcm/src/translations/ja.ts | 8 + .../dcm/plugins/dcm/src/translations/ref.ts | 10 +- 24 files changed, 864 insertions(+), 152 deletions(-) create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.test.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx diff --git a/workspaces/dcm/.changeset/replace-providers-with-agents.md b/workspaces/dcm/.changeset/replace-providers-with-agents.md index 53b8971b983..ea14560e927 100644 --- a/workspaces/dcm/.changeset/replace-providers-with-agents.md +++ b/workspaces/dcm/.changeset/replace-providers-with-agents.md @@ -1,14 +1,30 @@ --- -'@red-hat-developer-hub/backstage-plugin-dcm': minor -'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +'@red-hat-developer-hub/backstage-plugin-dcm': major +'@red-hat-developer-hub/backstage-plugin-dcm-common': major --- Replace Providers tab with Agents tab. -The Providers API has been removed by the DCM API team and is no longer available. -All provider-related UI, types, and client code have been removed. +**BREAKING CHANGES** + +The following `@public` exports have been removed: + +- `@red-hat-developer-hub/backstage-plugin-dcm-common`: `ProvidersApi`, `ProvidersClient`, `Provider`, `ProviderList`, `ProviderMetadata`, `ProviderStatus`, `ResourceCapacity` +- `@red-hat-developer-hub/backstage-plugin-dcm`: `providersApiRef` + +These symbols were removed because the Providers API has been deprecated by the +DCM API team and is no longer available. + +**Note:** DCM 1.x has no production consumers at this time. + +--- A new Agents tab has been added as the default landing tab, backed by the Agent API (v1alpha1). Agents register with the control plane and send periodic heartbeats. The UI supports listing agents with health-status filtering and registering new agents. + +**Follow-up**: FLPATH-4773 — rename the Resources tab "Provider" column to +"Environment" once the Resources API replaces `provider_name` with an agent +reference, and mark resources as degraded when the associated agent is +unavailable. diff --git a/workspaces/dcm/plugins/dcm-common/report.api.md b/workspaces/dcm/plugins/dcm-common/report.api.md index 1335c49da0e..ef42b8381e0 100644 --- a/workspaces/dcm/plugins/dcm-common/report.api.md +++ b/workspaces/dcm/plugins/dcm-common/report.api.md @@ -7,6 +7,86 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import type { FetchApi } from '@backstage/core-plugin-api'; +// @public +export interface Agent { + agent_id?: string; + cost: AgentCost; + create_time?: string; + environment: string; + health_status?: AgentHealthStatus; + last_heartbeat?: string; + name: string; + service_types: string[]; + topic_name: string; + update_time?: string; +} + +// @public +export type AgentCost = + | 'low' + | 'medium-low' + | 'medium' + | 'medium-high' + | 'high'; + +// @public +export type AgentHealthStatus = 'ready' | 'congested' | 'unavailable'; + +// @public +export interface AgentList { + // (undocumented) + agents?: Agent[]; + // (undocumented) + next_page_token?: string; +} + +// @public +export interface AgentRegistrationRequest { + // (undocumented) + cost: AgentCost; + // (undocumented) + environment: string; + // (undocumented) + name: string; + // (undocumented) + service_types: string[]; + topic_name: string; +} + +// @public +export interface AgentsApi { + // (undocumented) + agentHeartbeat(agentId: string, heartbeat: HeartbeatRequest): Promise; + // (undocumented) + createAgent(agent: AgentRegistrationRequest): Promise; + // (undocumented) + getAgent(agentId: string): Promise; + // (undocumented) + listAgents( + params?: PaginationParams & { + health_status?: AgentHealthStatus; + }, + ): Promise; +} + +// @public +export class AgentsClient extends DcmBaseClient implements AgentsApi { + // (undocumented) + agentHeartbeat(agentId: string, heartbeat: HeartbeatRequest): Promise; + // (undocumented) + createAgent(agent: AgentRegistrationRequest): Promise; + // (undocumented) + getAgent(agentId: string): Promise; + // (undocumented) + listAgents( + params?: PaginationParams & { + health_status?: AgentHealthStatus; + }, + ): Promise; + // (undocumented) + protected readonly serviceName = 'Agents'; +} + // @public export function buildPaginationQuery(params: PaginationParams): string; @@ -282,6 +362,12 @@ export interface FieldConfigurationDependsOn { path: string; } +// @public +export interface HeartbeatRequest { + consumer_lag: number; + timestamp: string; +} + // @public export interface ListServiceTypeInstancesParams { max_page_size?: number; @@ -364,97 +450,6 @@ export class PolicyManagerClient // @public export type PolicyType = 'GLOBAL' | 'USER'; -// @public -export interface Provider { - // (undocumented) - create_time?: string; - // (undocumented) - display_name?: string; - endpoint: string; - // (undocumented) - health_status?: string; - id?: string; - // (undocumented) - metadata?: ProviderMetadata; - // (undocumented) - name: string; - operations?: string[]; - path?: string; - schema_version: string; - // (undocumented) - service_type: string; - status?: ProviderStatus; - // (undocumented) - update_time?: string; -} - -// @public -export interface ProviderList { - // (undocumented) - next_page_token?: string; - // (undocumented) - providers?: Provider[]; -} - -// @public -export interface ProviderMetadata { - // (undocumented) - [key: string]: unknown; - // (undocumented) - region_code?: string; - // (undocumented) - resources?: ResourceCapacity; - // (undocumented) - status?: string; - // (undocumented) - zone?: string; -} - -// @public -export interface ProvidersApi { - // (undocumented) - applyProvider(providerId: string, provider: Provider): Promise; - // (undocumented) - createProvider(provider: Provider): Promise; - // (undocumented) - deleteProvider(providerId: string): Promise; - // (undocumented) - getProvider(providerId: string): Promise; - // (undocumented) - listProviders(params?: PaginationParams): Promise; -} - -// @public -export class ProvidersClient extends DcmBaseClient implements ProvidersApi { - // (undocumented) - applyProvider(providerId: string, provider: Provider): Promise; - // (undocumented) - createProvider(provider: Provider): Promise; - // (undocumented) - deleteProvider(providerId: string): Promise; - // (undocumented) - getProvider(providerId: string): Promise; - // (undocumented) - listProviders(params?: PaginationParams): Promise; - // (undocumented) - protected readonly serviceName = 'Providers'; -} - -// @public -export type ProviderStatus = 'registered' | 'updated'; - -// @public -export interface ResourceCapacity { - // (undocumented) - total_cpu?: number; - // (undocumented) - total_memory?: string; - // (undocumented) - total_node?: number; - // (undocumented) - total_storage?: string; -} - // @public export interface ResourcesApi { listServiceTypeInstances( diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts index 6979a329136..3245de5ef38 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts @@ -17,6 +17,7 @@ import type { PaginationParams } from '../types/common'; import type { Agent, + AgentHealthStatus, AgentList, AgentRegistrationRequest, HeartbeatRequest, @@ -29,7 +30,7 @@ import type { */ export interface AgentsApi { listAgents( - params?: PaginationParams & { health_status?: string }, + params?: PaginationParams & { health_status?: AgentHealthStatus }, ): Promise; getAgent(agentId: string): Promise; createAgent(agent: AgentRegistrationRequest): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts index f0b514084ed..4daad362b47 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts @@ -17,6 +17,7 @@ import type { PaginationParams } from '../types/common'; import type { Agent, + AgentHealthStatus, AgentList, AgentRegistrationRequest, HeartbeatRequest, @@ -38,7 +39,7 @@ export class AgentsClient extends DcmBaseClient implements AgentsApi { protected readonly serviceName = 'Agents'; async listAgents( - params: PaginationParams & { health_status?: string } = {}, + params: PaginationParams & { health_status?: AgentHealthStatus } = {}, ): Promise { const { health_status, ...pagination } = params; let query = buildPaginationQuery(pagination); diff --git a/workspaces/dcm/plugins/dcm/report.api.md b/workspaces/dcm/plugins/dcm/report.api.md index a04500fffdd..17b0d71dcce 100644 --- a/workspaces/dcm/plugins/dcm/report.api.md +++ b/workspaces/dcm/plugins/dcm/report.api.md @@ -3,12 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import type { AgentsApi } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import type { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import type { CatalogApi } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import type { PolicyManagerApi } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import type { ProvidersApi } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import type { ResourcesApi } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { RouteRef } from '@backstage/core-plugin-api'; import { SubRouteRef } from '@backstage/core-plugin-api'; @@ -16,6 +16,9 @@ import { Theme } from '@material-ui/core/styles'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; import { TranslationResource } from '@backstage/core-plugin-api/alpha'; +// @public +export const agentsApiRef: ApiRef; + // @public export const catalogApiRef: ApiRef; @@ -26,7 +29,7 @@ export const DcmPage: Router; export const dcmPlugin: BackstagePlugin< { root: RouteRef; - providers: SubRouteRef; + agents: SubRouteRef; policies: SubRouteRef; serviceTypes: SubRouteRef; catalogItems: SubRouteRef; @@ -52,9 +55,6 @@ export function isDarkMode(theme: Theme): boolean; // @public export const policyManagerApiRef: ApiRef; -// @public -export const providersApiRef: ApiRef; - // @public export const resourcesApiRef: ApiRef; diff --git a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx index 69cad39df60..cce5e249de3 100644 --- a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx @@ -29,6 +29,7 @@ import { } from '@material-ui/core'; import SyncIcon from '@material-ui/icons/Sync'; import { Dispatch, SetStateAction } from 'react'; +import type React from 'react'; import MuiAlert from '@material-ui/lab/Alert'; import type { BoxProps } from '@material-ui/core/Box'; import { DcmDataCenterTabEmptyState } from './DcmDataCenterTabEmptyState'; @@ -93,6 +94,18 @@ export type DcmCrudTabLayoutProps = Readonly<{ // ── Card header ────────────────────────────────────────────────────────── entityLabel: string; + // ── Extra toolbar content ──────────────────────────────────────────────── + /** Optional content rendered alongside the primary action button in the toolbar row. */ + toolbarExtra?: React.ReactNode; + + /** + * When true, the global illustration empty-state is suppressed so that the + * toolbar (and any `toolbarExtra` controls) remains visible. Use this when + * the caller has an active filter that may be the cause of the empty result — + * the user needs to be able to change the filter without a full page reload. + */ + hasActiveFilter?: boolean; + // ── Refresh ────────────────────────────────────────────────────────────── /** When provided, a refresh icon button is shown next to the search field. */ onRefresh?: () => void; @@ -159,6 +172,8 @@ export function DcmCrudTabLayout({ onPrimaryAction, illustrationSrc, entityLabel, + toolbarExtra, + hasActiveFilter, onRefresh, refreshing, }: DcmCrudTabLayoutProps) { @@ -191,9 +206,15 @@ export function DcmCrudTabLayout({ // empty (i.e. not just an empty cursor page on page 2+). If hasPrev is true // the user deleted the last row on a non-first page — fall through to the // table view so cursor controls remain accessible. - if (items.length === 0 && !cursorPagination?.hasPrev) { + // When a filter is active the empty result may be filter-induced; skip the + // illustration empty-state so the toolbar (with the filter control) stays + // visible and the user can clear the filter without a full page reload. + if (items.length === 0 && !cursorPagination?.hasPrev && !hasActiveFilter) { return ( <> + {toolbarExtra && ( + {toolbarExtra} + )} {actionError && ( ({ + {toolbarExtra} { }); }); - // ── handlePageSizeChange ──────────────────────────────────────────────────── + // ── resetAndReload ────────────────────────────────────────────────────────── + + describe('resetAndReload', () => { + it('resets pageToken to undefined and hasPrev to false after goNext', async () => { + const loadFn = jest + .fn() + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }) + .mockResolvedValueOnce({ items: [...PAGE_2], nextPageToken: '' }) + .mockResolvedValue({ items: [...PAGE_1], nextPageToken: 'tok2' }); + + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + // load page 1 + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(false); + + // advance to page 2 + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(true); + + // reset and reload + act(() => result.current.resetAndReload()); + await waitFor(() => expect(result.current.loading).toBe(false)); + // pageToken must be undefined on the reload call + const resetCallArgs = ( + loadFn.mock.calls[loadFn.mock.calls.length - 1] as [PaginatedLoadParams] + )[0]; + expect(resetCallArgs.pageToken).toBeUndefined(); + + // hasPrev (token stack) must be cleared + expect(result.current.cursorPagination.hasPrev).toBe(false); + }); + }); + + // ── handlePageSizeChange ──────────────────────────────────────────────────── describe('handlePageSizeChange', () => { it('resets to page 1 (undefined token) and reloads with the new size', async () => { const loadFn = jest diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts index 1d44ceb62eb..7ba852dd98e 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts @@ -51,7 +51,7 @@ export interface UsePaginatedCrudTabOptions< loadFn: (params: PaginatedLoadParams) => Promise>; /** * `localStorage` key used to persist the selected page size (e.g. - * `'providers'`, `'policies'`). Must be unique per table. + * `'agents'`, `'policies'`). Must be unique per table. */ storageKey: string; } @@ -69,6 +69,11 @@ export interface UsePaginatedCrudTabResult> extends UseCrudTabResult { goNext: () => void; goPrev: () => void; + /** + * Resets cursor navigation to page 1 and triggers a reload. + * Use this when external filter state changes to restart from the first page. + */ + resetAndReload: () => void; /** * Drop-in replacement for `crud.setSearch`. Search is client-side filtering * on the loaded page; cursor state (Prev/Next) is unchanged. @@ -91,12 +96,12 @@ export interface UsePaginatedCrudTabResult> * `handleSearchChange` themselves. * * @example - * const crud = usePaginatedCrudTab({ + * const crud = usePaginatedCrudTab({ * loadFn: ({ pageToken, pageSize }) => - * providersApi.listProviders({ page_token: pageToken, max_page_size: pageSize }) - * .then(r => ({ items: r.providers ?? [], nextPageToken: r.next_page_token })), - * storageKey: 'providers', - * createFn: form => providersApi.createProvider(form), + * agentsApi.listAgents({ page_token: pageToken, max_page_size: pageSize }) + * .then(r => ({ items: r.agents ?? [], nextPageToken: r.next_page_token })), + * storageKey: 'agents', + * createFn: form => agentsApi.createAgent(formToAgentRegistration(form)), * ... * }); * @@ -160,6 +165,14 @@ export function usePaginatedCrudTab>( crudReload(); }, [crudReload]); + // Resets cursor navigation to page 1 and triggers a reload. + const resetAndReload = useCallback(() => { + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + crudReload(); + }, [crudReload]); + // When the page size changes, update the ref immediately (so the next reload // uses the new size without waiting for a re-render), reset the cursor to the // first page, and trigger a reload. @@ -167,12 +180,9 @@ export function usePaginatedCrudTab>( (newSize: number) => { pageSizeRef.current = newSize; setPageSize(newSize); - currentTokenRef.current = undefined; - tokenStackRef.current = []; - setTokenStack([]); - crudReload(); + resetAndReload(); }, - [setPageSize, crudReload], + [setPageSize, resetAndReload], ); // Search is client-side filtering on the already-loaded page; cursor state @@ -188,6 +198,7 @@ export function usePaginatedCrudTab>( ...crud, goNext, goPrev, + resetAndReload, handleSearchChange, handlePageSizeChange, cursorPagination: { diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx index 3970c2fd257..4e3ad892adc 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx @@ -17,7 +17,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import type { Agent } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { agentsApiRef } from '../../apis'; +import { agentsApiRef, catalogApiRef } from '../../apis'; import { AgentsTabContent } from './AgentsTabContent'; jest.mock('../../hooks/useTranslation', () => { @@ -35,6 +35,24 @@ const MOCK_AGENT: Agent = { health_status: 'ready', }; +const baseCatalogApi = { + listServiceTypes: jest + .fn() + .mockResolvedValue({ results: [], next_page_token: undefined }), + getServiceType: jest.fn(), + createServiceType: jest.fn(), + listCatalogItems: jest.fn(), + getCatalogItem: jest.fn(), + createCatalogItem: jest.fn(), + updateCatalogItem: jest.fn(), + deleteCatalogItem: jest.fn(), + listCatalogItemInstances: jest.fn(), + getCatalogItemInstance: jest.fn(), + createCatalogItemInstance: jest.fn(), + rehydrateCatalogItemInstance: jest.fn(), + deleteCatalogItemInstance: jest.fn(), +}; + const baseAgentsApi = { listAgents: jest.fn().mockResolvedValue({ agents: [MOCK_AGENT], @@ -46,14 +64,22 @@ const baseAgentsApi = { }; function buildApis(overrides: Partial = {}) { - return { agents: { ...baseAgentsApi, ...overrides } }; + return { + agents: { ...baseAgentsApi, ...overrides }, + catalog: baseCatalogApi, + }; } async function renderAgentsTab( apis: ReturnType = buildApis(), ) { return renderInTestApp( - + , ); @@ -205,4 +231,49 @@ describe('AgentsTabContent', () => { ); }); }); + + describe('health-status filter', () => { + it('calls listAgents with health_status when a filter is selected', async () => { + const listAgents = jest + .fn() + .mockResolvedValue({ agents: [MOCK_AGENT], next_page_token: '' }); + const apis = buildApis({ listAgents }); + await renderAgentsTab(apis); + + await waitFor(() => expect(listAgents).toHaveBeenCalledTimes(1)); + + const filterInput = document.querySelector( + '[data-testid="health-filter"]', + ) as HTMLInputElement; + fireEvent.change(filterInput, { target: { value: 'ready' } }); + + await waitFor(() => + expect(listAgents).toHaveBeenCalledWith( + expect.objectContaining({ health_status: 'ready' }), + ), + ); + }); + + it('calls listAgents without health_status when "All" is selected', async () => { + const listAgents = jest + .fn() + .mockResolvedValue({ agents: [MOCK_AGENT], next_page_token: '' }); + const apis = buildApis({ listAgents }); + await renderAgentsTab(apis); + + const filterInput = document.querySelector( + '[data-testid="health-filter"]', + ) as HTMLInputElement; + fireEvent.change(filterInput, { target: { value: 'ready' } }); + await waitFor(() => expect(listAgents).toHaveBeenCalledTimes(2)); + + fireEvent.change(filterInput, { target: { value: '' } }); + + await waitFor(() => + expect(listAgents).toHaveBeenCalledWith( + expect.not.objectContaining({ health_status: expect.anything() }), + ), + ); + }); + }); }); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx index b54f743025f..a606b89d132 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx @@ -14,23 +14,36 @@ * limitations under the License. */ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { TableColumn } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; -import { Box, Chip, Tooltip } from '@material-ui/core'; +import { + Box, + Chip, + FormControl, + InputLabel, + MenuItem, + Select, + Tooltip, + Typography, +} from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import type { Agent } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; -import { agentsApiRef } from '../../apis'; +import type { + Agent, + AgentHealthStatus, +} from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { agentsApiRef, catalogApiRef } from '../../apis'; import { DcmCrudTabLayout } from '../../components/DcmCrudTabLayout'; import { DcmFormDialog } from '../../components/DcmFormDialog'; import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; +import { useInfiniteSelect } from '../../hooks/useInfiniteSelect'; import { useTranslation } from '../../hooks/useTranslation'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { TruncatedText, DcmEmptyCell } from '../../components/TruncatedText'; -import { AgentHealthStatus } from './components/AgentHealthStatus'; +import { AgentHealthStatus as AgentHealthStatusBadge } from './components/AgentHealthStatus'; import { AgentFormFields } from './components/AgentFormFields'; import { CopyButton } from './components/CopyButton'; import { @@ -49,17 +62,62 @@ const useStyles = makeStyles(theme => ({ flexWrap: 'wrap', gap: theme.spacing(0.5), }, + healthFilter: { + minWidth: 140, + }, })); export function AgentsTabContent() { const classes = useStyles(); const agentsApi = useApi(agentsApiRef); + const catalogApi = useApi(catalogApiRef); const { t } = useTranslation(); + const [healthFilter, setHealthFilter] = useState(''); + const [serviceTypesErrorDismissed, setServiceTypesErrorDismissed] = + useState(false); + + const healthFilterOptions = useMemo( + () => [ + { + value: 'ready' as AgentHealthStatus, + label: t('agents.filter.healthReady'), + }, + { + value: 'congested' as AgentHealthStatus, + label: t('agents.filter.healthCongested'), + }, + { + value: 'unavailable' as AgentHealthStatus, + label: t('agents.filter.healthUnavailable'), + }, + ], + [t], + ); + + const { + items: serviceTypes, + loading: loadingServiceTypes, + loadingMore: loadingMoreServiceTypes, + loadMore: loadMoreServiceTypes, + error: serviceTypesError, + } = useInfiniteSelect((token?: string) => + catalogApi.listServiceTypes({ max_page_size: 100, page_token: token }), + ); + + // Keep the latest filter value accessible inside the loadFn without + // causing the hook to re-initialise when the filter changes. + const healthFilterRef = useRef(healthFilter); + healthFilterRef.current = healthFilter; + const crud = usePaginatedCrudTab({ loadFn: ({ pageToken, pageSize: ps }) => agentsApi - .listAgents({ page_token: pageToken, max_page_size: ps }) + .listAgents({ + page_token: pageToken, + max_page_size: ps, + health_status: healthFilterRef.current || undefined, + }) .then(r => ({ items: r.agents ?? [], nextPageToken: r.next_page_token, @@ -73,6 +131,18 @@ export function AgentsTabContent() { createSuccessMessage: t('agents.createSuccess'), }); + // Reset cursor and reload whenever the health filter changes, but skip the + // initial render (the hook already loads on mount). + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + crud.resetAndReload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [healthFilter]); + const columns = useMemo[]>( () => [ { @@ -113,6 +183,7 @@ export function AgentsTabContent() { sorting: false, render: a => { const types = a.service_types ?? []; + if (types.length === 0) return ; const VISIBLE = 2; const visible = types.slice(0, VISIBLE); const rest = types.slice(VISIBLE); @@ -163,12 +234,53 @@ export function AgentsTabContent() { { title: t('agents.columns.health'), field: 'health_status', - render: a => , + render: a => , + }, + { + title: t('agents.columns.lastHeartbeat'), + field: 'last_heartbeat', + render: a => + a.last_heartbeat ? ( + + {new Date(a.last_heartbeat).toLocaleString()} + + ) : ( + + ), }, ], [classes, t], ); + const healthFilterControl = ( + + + {t('agents.filter.healthLabel')} + + + + ); + return ( <> @@ -188,6 +300,10 @@ export function AgentsTabContent() { onPrimaryAction={crud.handleOpenCreate} illustrationSrc={emptyIllustration} entityLabel={t('agents.entityLabel')} + toolbarExtra={healthFilterControl} + hasActiveFilter={Boolean(healthFilter)} + actionError={serviceTypesErrorDismissed ? null : serviceTypesError} + onDismissActionError={() => setServiceTypesErrorDismissed(true)} /> diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts index 159b7540558..c3d0a0f9690 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts @@ -68,6 +68,15 @@ describe('isAgentFormValid', () => { expect(isAgentFormValid({ ...VALID_FORM, cost: '' })).toBe(false); }); + it('returns false when cost is not a valid option', () => { + expect( + isAgentFormValid({ + ...VALID_FORM, + cost: 'not-a-cost' as AgentForm['cost'], + }), + ).toBe(false); + }); + it('returns false when topic_name is empty', () => { expect(isAgentFormValid({ ...VALID_FORM, topic_name: '' })).toBe(false); }); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts index ea1c60b8247..4cdb59f84c7 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts @@ -54,6 +54,7 @@ function buildAgentSchema(t?: TFunction) { ), environment: yup .string() + .trim() .required( m('validation.agent.environmentRequired', 'Environment is required'), ), @@ -69,6 +70,10 @@ function buildAgentSchema(t?: TFunction) { ), cost: yup .string() + .oneOf( + AGENT_COST_OPTIONS as string[], + m('validation.agent.costRequired', 'Cost is required'), + ) .required(m('validation.agent.costRequired', 'Cost is required')), topic_name: yup .string() diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.test.tsx new file mode 100644 index 00000000000..94c3d6af7a2 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AgentFormFields, AgentFormFieldsProps } from './AgentFormFields'; +import { emptyAgentForm, AgentForm } from '../agentFormTypes'; + +jest.mock('../../../hooks/useTranslation', () => { + const mod = require('../../../test-utils/mockTranslations'); + return { useTranslation: mod.mockUseTranslation }; +}); + +type TouchedMap = Partial>; + +function Wrapper( + props: Readonly< + Pick< + AgentFormFieldsProps, + 'serviceTypes' | 'loadingServiceTypes' | 'loadingMoreServiceTypes' + > + >, +) { + const [form, setForm] = useState(emptyAgentForm()); + const [touched, setTouched] = useState({}); + return ( + + ); +} + +const NAME_PLACEHOLDER = 'e.g. env-agent-west-1'; +const ENV_PLACEHOLDER = 'e.g. production'; +const TOPIC_PLACEHOLDER = 'e.g. dcm.agent.env-agent-west-1'; + +describe('AgentFormFields', () => { + it('renders all form fields', () => { + render(); + + expect(screen.getByPlaceholderText(NAME_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(ENV_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(TOPIC_PLACEHOLDER)).toBeInTheDocument(); + // Service types and Cost selects exist via their label text + expect(screen.getAllByText(/service types \*/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/cost \*/i).length).toBeGreaterThan(0); + }); + + it('Name field updates its value when the user types', async () => { + render(); + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + await userEvent.type(nameInput, 'my-agent'); + expect(nameInput).toHaveValue('my-agent'); + }); + + it('shows a validation error after blurring Name with an invalid value', async () => { + render(); + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + await userEvent.type(nameInput, 'INVALID NAME'); + fireEvent.blur(nameInput); + expect( + screen.getByText( + /only lowercase letters, numbers, and hyphens are allowed/i, + ), + ).toBeInTheDocument(); + }); + + it('does not show a validation error for a valid Name slug', async () => { + render(); + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + await userEvent.type(nameInput, 'env-agent-west-1'); + fireEvent.blur(nameInput); + expect( + screen.queryByText( + /only lowercase letters, numbers, and hyphens are allowed/i, + ), + ).not.toBeInTheDocument(); + }); + + it('shows the helper text for the Name field', () => { + render(); + expect( + screen.getByText(/unique slug identifier.*only lowercase letters/i), + ).toBeInTheDocument(); + }); + + describe('service types dropdown', () => { + it('shows "Service types this agent can provide" placeholder when list is empty', () => { + render(); + // Helper text under the field + expect( + screen.getAllByText(/service types this agent can provide/i).length, + ).toBeGreaterThan(0); + }); + + it('renders provided service type options when the dropdown is opened', async () => { + const serviceTypes = [ + { uid: '1', service_type: 'vm', api_version: 'v1', spec: {} }, + { uid: '2', service_type: 'container', api_version: 'v1', spec: {} }, + ]; + render(); + + fireEvent.mouseDown( + screen.getByRole('button', { name: /service types/i }), + ); + + expect(await screen.findByText('vm')).toBeInTheDocument(); + expect(await screen.findByText('container')).toBeInTheDocument(); + }); + }); + + describe('topic_name auto-fill', () => { + it('auto-fills topic_name with dcm.agent. as the user types the name', async () => { + render(); + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + const topicInput = screen.getByPlaceholderText(TOPIC_PLACEHOLDER); + + await userEvent.type(nameInput, 'my-agent'); + + expect(topicInput).toHaveValue('dcm.agent.my-agent'); + }); + + it('stops auto-filling topic_name once the user manually edits it', async () => { + render(); + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + const topicInput = screen.getByPlaceholderText(TOPIC_PLACEHOLDER); + + // Type a name first so auto-fill has fired + await userEvent.type(nameInput, 'my-agent'); + expect(topicInput).toHaveValue('dcm.agent.my-agent'); + + // Manually edit the topic + await userEvent.clear(topicInput); + await userEvent.type(topicInput, 'dcm.agent.custom'); + + // Now change the name again + await userEvent.type(nameInput, '-extra'); + + // topic_name should remain at the manually edited value + expect(topicInput).toHaveValue('dcm.agent.custom'); + }); + + it('resumes auto-filling after the form is reset to empty (simulates dialog close + reopen)', async () => { + // The Wrapper re-initialises form state via useState(emptyAgentForm()), + // so remounting it simulates what happens when useCrudTab resets createForm. + const { unmount } = render(); + unmount(); + render(); + + const nameInput = screen.getByPlaceholderText(NAME_PLACEHOLDER); + const topicInput = screen.getByPlaceholderText(TOPIC_PLACEHOLDER); + + await userEvent.type(nameInput, 'new-agent'); + + // topic_name must auto-fill on a fresh form instance + expect(topicInput).toHaveValue('dcm.agent.new-agent'); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx index bf3c4521de5..36cfd860a41 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx @@ -18,6 +18,7 @@ import { useMemo } from 'react'; import { Box, Chip, + CircularProgress, FormControl, FormHelperText, InputLabel, @@ -26,6 +27,8 @@ import { Select, TextField, } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import type { ServiceType } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { AGENT_COST_OPTIONS, AgentForm, @@ -33,6 +36,20 @@ import { } from '../agentFormTypes'; import { useTranslation } from '../../../hooks/useTranslation'; +const useStyles = makeStyles(theme => ({ + placeholderText: { + color: + theme.palette.type === 'dark' + ? 'rgba(255,255,255,0.5)' + : 'rgba(0,0,0,0.38)', + }, + chipWrap: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: 4, + }, +})); + type TouchedMap = Partial>; export type AgentFormFieldsProps = Readonly<{ @@ -40,6 +57,10 @@ export type AgentFormFieldsProps = Readonly<{ setForm: React.Dispatch>; touched: TouchedMap; setTouched: React.Dispatch>; + serviceTypes?: ServiceType[]; + loadingServiceTypes?: boolean; + loadingMoreServiceTypes?: boolean; + loadMoreServiceTypes?: () => void; }>; export function AgentFormFields({ @@ -47,7 +68,12 @@ export function AgentFormFields({ setForm, touched, setTouched, + serviceTypes = [], + loadingServiceTypes = false, + loadingMoreServiceTypes = false, + loadMoreServiceTypes, }: AgentFormFieldsProps) { + const classes = useStyles(); const { t } = useTranslation(); const errors = useMemo(() => validateAgentForm(form, t), [form, t]); @@ -64,7 +90,19 @@ export function AgentFormFields({ helperText={err('name') ?? t('agents.form.nameHelper')} error={Boolean(err('name'))} value={form.name} - onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))} + onChange={e => { + const newName = e.target.value; + setForm(prev => { + const generated = + prev.topic_name === '' || + prev.topic_name === `dcm.agent.${prev.name}`; + return { + ...prev, + name: newName, + ...(generated ? { topic_name: `dcm.agent.${newName}` } : {}), + }; + }); + }} onBlur={() => touch('name')} fullWidth variant="outlined" @@ -110,22 +148,54 @@ export function AgentFormFields({ onBlur={() => touch('service_types')} renderValue={selected => (selected as string[]).length === 0 ? ( - + {t('agents.form.serviceTypesHelper')} ) : ( - + {(selected as string[]).map(val => ( ))} ) } + MenuProps={{ + MenuListProps: { + onScroll: (e: React.UIEvent) => { + const el = e.currentTarget; + if ( + el.scrollHeight - el.scrollTop <= el.clientHeight + 40 && + loadMoreServiceTypes + ) { + loadMoreServiceTypes(); + } + }, + }, + }} displayEmpty > - - {t('agents.form.serviceTypesHelper')} - + {serviceTypes.length === 0 && + !loadingServiceTypes && + !loadingMoreServiceTypes && ( + + {t('agents.form.serviceTypesHelper')} + + )} + {loadingServiceTypes && ( + + + + )} + {serviceTypes.map(st => ( + + {st.service_type} + + ))} + {loadingMoreServiceTypes && ( + + + + )} {err('service_types') ?? t('agents.form.serviceTypesHelper')} diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx index 29fcc3bf977..b1114d95202 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentHealthStatus.tsx @@ -18,6 +18,7 @@ import { StatusOK, StatusWarning, StatusError, + StatusPending, } from '@backstage/core-components'; import { Box, makeStyles, Typography } from '@material-ui/core'; @@ -72,9 +73,11 @@ export function AgentHealthStatus({ StatusComponent = StatusWarning; break; case 'unavailable': - default: StatusComponent = StatusError; break; + default: + StatusComponent = StatusPending; + break; } return ( diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx new file mode 100644 index 00000000000..60134268b00 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CopyButton } from './CopyButton'; + +jest.mock('../../../hooks/useTranslation', () => { + const mod = require('../../../test-utils/mockTranslations'); + return { useTranslation: mod.mockUseTranslation }; +}); + +const writeTextMock = jest.fn(); + +beforeAll(() => { + Object.assign(globalThis.navigator, { + clipboard: { writeText: writeTextMock }, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('CopyButton', () => { + it('renders the copy button with the default aria-label', () => { + render(); + expect( + screen.getByRole('button', { name: /copy to clipboard/i }), + ).toBeInTheDocument(); + }); + + it('shows checkmark icon after a successful copy', async () => { + writeTextMock.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + + await waitFor(() => + expect(writeTextMock).toHaveBeenCalledWith('https://example.com'), + ); + + // After success the CheckIcon must be present + await waitFor(() => + expect( + document.querySelector('[data-testid="CopyButton-check"]'), + ).toBeInTheDocument(), + ); + }); + + it('shows error icon after a failed clipboard write', async () => { + writeTextMock.mockRejectedValue(new Error('Permission denied')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + + await waitFor(() => + expect(writeTextMock).toHaveBeenCalledWith('https://example.com'), + ); + + // After failure the ErrorOutlineIcon must be present + await waitFor(() => + expect( + document.querySelector('[data-testid="CopyButton-error"]'), + ).toBeInTheDocument(), + ); + }); + + it('passes the text prop to clipboard.writeText', async () => { + writeTextMock.mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + + await waitFor(() => + expect(writeTextMock).toHaveBeenCalledWith('some-endpoint-url'), + ); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx index 845c2008430..1c52720d334 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx @@ -16,43 +16,80 @@ import { useState } from 'react'; import { IconButton, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import CheckIcon from '@material-ui/icons/Check'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; import { useTranslation } from '../../../hooks/useTranslation'; +const useStyles = makeStyles(theme => ({ + copyButton: { + padding: theme.spacing(0.25), + marginLeft: theme.spacing(0.5), + }, + copiedIcon: { + fontSize: 14, + color: theme.palette.success.main, + }, + failedIcon: { + fontSize: 14, + color: theme.palette.error.main, + }, + copyIcon: { + fontSize: 14, + }, +})); + type CopyState = 'idle' | 'copied' | 'failed'; +/** Icon button that copies text to the clipboard and shows a brief checkmark. */ export function CopyButton({ text }: Readonly<{ text: string }>) { + const classes = useStyles(); const { t } = useTranslation(); const [state, setState] = useState('idle'); - const handleCopy = async () => { - try { - await window.navigator.clipboard.writeText(text); - setState('copied'); - } catch { - setState('failed'); - } finally { - setTimeout(() => setState('idle'), 2000); - } + const handleCopy = () => { + globalThis.navigator.clipboard + .writeText(text) + .then(() => { + setState('copied'); + setTimeout(() => setState('idle'), 2000); + }) + .catch(() => { + setState('failed'); + setTimeout(() => setState('idle'), 2000); + }); }; - let label: string; - if (state === 'copied') { - label = t('copyButton.copied'); - } else if (state === 'failed') { - label = t('copyButton.failed'); - } else { - label = t('copyButton.copy'); - } + let tooltipTitle = t('copyButton.copy'); + if (state === 'copied') tooltipTitle = t('copyButton.copied'); + else if (state === 'failed') tooltipTitle = t('copyButton.failed'); + + let icon = ; + if (state === 'copied') + icon = ( + + ); + else if (state === 'failed') + icon = ( + + ); return ( - + - + {icon} ); diff --git a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx index 23543047dab..d2ee0f1a0ca 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx @@ -100,6 +100,9 @@ export function ResourcesTabContent() { ), }, { + // TODO(FLPATH-4773): Rename column to "Environment" once the Resources + // API replaces provider_name with an agent/environment reference, and + // mark resources as degraded when the associated agent is unavailable. title: t('resources.columns.provider'), field: 'provider_name', render: inst => ( diff --git a/workspaces/dcm/plugins/dcm/src/translations/de.ts b/workspaces/dcm/plugins/dcm/src/translations/de.ts index b2e4ed1b1ed..cee436e2789 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/de.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/de.ts @@ -68,6 +68,12 @@ const dcmTranslationDe: TranslationMessages< 'agents.columns.cost': 'Kosten', 'agents.columns.topic': 'Topic', 'agents.columns.health': 'Zustand', + 'agents.columns.lastHeartbeat': 'Letzter Herzschlag', + 'agents.filter.healthLabel': 'Gesundheitsstatus', + 'agents.filter.healthAll': 'Alle', + 'agents.filter.healthReady': 'Bereit', + 'agents.filter.healthCongested': 'Überlastet', + 'agents.filter.healthUnavailable': 'Nicht verfügbar', 'agents.form.nameLabel': 'Name *', 'agents.form.namePlaceholder': 'z.\u00a0B. env-agent-west-1', 'agents.form.nameHelper': diff --git a/workspaces/dcm/plugins/dcm/src/translations/es.ts b/workspaces/dcm/plugins/dcm/src/translations/es.ts index c2af37d50b9..ef245fc2f41 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/es.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/es.ts @@ -68,6 +68,12 @@ const dcmTranslationEs: TranslationMessages< 'agents.columns.cost': 'Coste', 'agents.columns.topic': 'Topic', 'agents.columns.health': 'Estado', + 'agents.columns.lastHeartbeat': 'Último latido', + 'agents.filter.healthLabel': 'Estado de salud', + 'agents.filter.healthAll': 'Todos', + 'agents.filter.healthReady': 'Listo', + 'agents.filter.healthCongested': 'Congestionado', + 'agents.filter.healthUnavailable': 'No disponible', 'agents.form.nameLabel': 'Nombre *', 'agents.form.namePlaceholder': 'p.\u00a0ej. env-agent-west-1', 'agents.form.nameHelper': diff --git a/workspaces/dcm/plugins/dcm/src/translations/fr.ts b/workspaces/dcm/plugins/dcm/src/translations/fr.ts index 1d3add66a3d..e13afdbf070 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/fr.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/fr.ts @@ -68,6 +68,12 @@ const dcmTranslationFr: TranslationMessages< 'agents.columns.cost': 'Co\u00fbt', 'agents.columns.topic': 'Topic', 'agents.columns.health': '\u00c9tat', + 'agents.columns.lastHeartbeat': 'Dernier battement de cœur', + 'agents.filter.healthLabel': 'État de santé', + 'agents.filter.healthAll': 'Tous', + 'agents.filter.healthReady': 'Prêt', + 'agents.filter.healthCongested': 'Congestionné', + 'agents.filter.healthUnavailable': 'Indisponible', 'agents.form.nameLabel': 'Nom *', 'agents.form.namePlaceholder': 'p.\u00a0ex. env-agent-west-1', 'agents.form.nameHelper': diff --git a/workspaces/dcm/plugins/dcm/src/translations/it.ts b/workspaces/dcm/plugins/dcm/src/translations/it.ts index 049fe7cbf92..fd77d0b9c2e 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/it.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/it.ts @@ -68,6 +68,12 @@ const dcmTranslationIt: TranslationMessages< 'agents.columns.cost': 'Costo', 'agents.columns.topic': 'Topic', 'agents.columns.health': 'Stato', + 'agents.columns.lastHeartbeat': 'Ultimo heartbeat', + 'agents.filter.healthLabel': 'Stato di salute', + 'agents.filter.healthAll': 'Tutti', + 'agents.filter.healthReady': 'Pronto', + 'agents.filter.healthCongested': 'Congestionato', + 'agents.filter.healthUnavailable': 'Non disponibile', 'agents.form.nameLabel': 'Nome *', 'agents.form.namePlaceholder': 'es. env-agent-west-1', 'agents.form.nameHelper': diff --git a/workspaces/dcm/plugins/dcm/src/translations/ja.ts b/workspaces/dcm/plugins/dcm/src/translations/ja.ts index fe07e6dce0f..d7dd92ef856 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ja.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ja.ts @@ -72,6 +72,14 @@ const dcmTranslationJa: TranslationMessages< 'agents.columns.cost': '\u30b3\u30b9\u30c8', 'agents.columns.topic': 'Topic', 'agents.columns.health': '\u30d8\u30eb\u30b9', + 'agents.columns.lastHeartbeat': + '\u6700\u5f8c\u306e\u30cf\u30fc\u30c8\u30d3\u30fc\u30c8', + 'agents.filter.healthLabel': + '\u30d8\u30eb\u30b9\u30b9\u30c6\u30fc\u30bf\u30b9', + 'agents.filter.healthAll': 'すべて', + 'agents.filter.healthReady': '準備完了', + 'agents.filter.healthCongested': '輻輳', + 'agents.filter.healthUnavailable': '利用不可', 'agents.form.nameLabel': '\u540d\u524d *', 'agents.form.namePlaceholder': '\u4f8b: env-agent-west-1', 'agents.form.nameHelper': diff --git a/workspaces/dcm/plugins/dcm/src/translations/ref.ts b/workspaces/dcm/plugins/dcm/src/translations/ref.ts index 2f16a1792d4..d346e4ca9d2 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ref.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ref.ts @@ -76,6 +76,14 @@ export const dcmMessages = { cost: 'Cost', topic: 'Topic', health: 'Health', + lastHeartbeat: 'Last heartbeat', + }, + filter: { + healthLabel: 'Health status', + healthAll: 'All', + healthReady: 'Ready', + healthCongested: 'Congested', + healthUnavailable: 'Unavailable', }, form: { nameLabel: 'Name *', @@ -238,7 +246,7 @@ export const dcmMessages = { instances: { emptyTitle: 'No instances provisioned', emptyDescription: - 'Catalog item instances represent provisioned services. Create an instance from a catalog item to provision a service on the registered provider infrastructure.', + 'Catalog item instances represent provisioned services. Create an instance from a catalog item to provision a service on a registered environment agent.', createButton: 'Create', entityLabel: 'Catalog item instances', createDialogTitle: 'Create catalog item instance',