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..ea14560e927 --- /dev/null +++ b/workspaces/dcm/.changeset/replace-providers-with-agents.md @@ -0,0 +1,30 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm': major +'@red-hat-developer-hub/backstage-plugin-dcm-common': major +--- + +Replace Providers tab with Agents tab. + +**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/ProvidersApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts similarity index 57% rename from workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts rename to workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts index 21098fd466d..3245de5ef38 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsApi.ts @@ -15,17 +15,24 @@ */ import type { PaginationParams } from '../types/common'; -import type { Provider, ProviderList } from '../types/providers'; +import type { + Agent, + AgentHealthStatus, + 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?: AgentHealthStatus }, + ): 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..4daad362b47 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/clients/AgentsClient.ts @@ -0,0 +1,73 @@ +/* + * 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, + AgentHealthStatus, + 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?: AgentHealthStatus } = {}, + ): 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/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/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/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/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..4e3ad892adc --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.test.tsx @@ -0,0 +1,279 @@ +/* + * 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, catalogApiRef } 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 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], + next_page_token: undefined, + }), + createAgent: jest.fn(), + getAgent: jest.fn(), + agentHeartbeat: jest.fn(), +}; + +function buildApis(overrides: Partial = {}) { + return { + agents: { ...baseAgentsApi, ...overrides }, + catalog: baseCatalogApi, + }; +} + +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(), + ); + }); + }); + + 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 new file mode 100644 index 00000000000..a606b89d132 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/AgentsTabContent.tsx @@ -0,0 +1,343 @@ +/* + * 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, useRef, useState } from 'react'; +import { TableColumn } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { + Box, + Chip, + FormControl, + InputLabel, + MenuItem, + Select, + Tooltip, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +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 as AgentHealthStatusBadge } 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), + }, + 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, + health_status: healthFilterRef.current || undefined, + }) + .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'), + }); + + // 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[]>( + () => [ + { + 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 ?? []; + if (types.length === 0) return ; + 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 => , + }, + { + 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 ( + <> + + 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')} + 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 new file mode 100644 index 00000000000..c3d0a0f9690 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.test.ts @@ -0,0 +1,171 @@ +/* + * 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 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); + }); + + 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..4cdb59f84c7 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/agentFormTypes.ts @@ -0,0 +1,136 @@ +/* + * 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() + .trim() + .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() + .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() + .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.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 new file mode 100644 index 00000000000..36cfd860a41 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/AgentFormFields.tsx @@ -0,0 +1,254 @@ +/* + * 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, + CircularProgress, + FormControl, + FormHelperText, + InputLabel, + MenuItem, + OutlinedInput, + 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, + validateAgentForm, +} 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<{ + form: AgentForm; + setForm: React.Dispatch>; + touched: TouchedMap; + setTouched: React.Dispatch>; + serviceTypes?: ServiceType[]; + loadingServiceTypes?: boolean; + loadingMoreServiceTypes?: boolean; + loadMoreServiceTypes?: () => void; +}>; + +export function AgentFormFields({ + form, + setForm, + touched, + setTouched, + serviceTypes = [], + loadingServiceTypes = false, + loadingMoreServiceTypes = false, + loadMoreServiceTypes, +}: AgentFormFieldsProps) { + const classes = useStyles(); + 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 ( + + { + 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" + 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 58% 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..b1114d95202 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,7 @@ import { StatusOK, StatusWarning, StatusError, - StatusAborted, StatusPending, - StatusRunning, } from '@backstage/core-components'; import { Box, makeStyles, Typography } from '@material-ui/core'; @@ -46,17 +44,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 +64,20 @@ 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': + StatusComponent = StatusError; + break; + default: + StatusComponent = StatusPending; + break; } return ( diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx similarity index 78% rename from workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx rename to workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx index fa83ba04ba5..60134268b00 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.test.tsx @@ -42,7 +42,7 @@ describe('CopyButton', () => { ).toBeInTheDocument(); }); - it('shows checkmark and Copied! tooltip after a successful copy', async () => { + it('shows checkmark icon after a successful copy', async () => { writeTextMock.mockResolvedValue(undefined); render(); @@ -52,11 +52,12 @@ describe('CopyButton', () => { 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(); + // 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 () => { @@ -69,12 +70,11 @@ describe('CopyButton', () => { expect(writeTextMock).toHaveBeenCalledWith('https://example.com'), ); - // After failure the ErrorOutlineIcon should be rendered + // After failure the ErrorOutlineIcon must be present await waitFor(() => expect( - document.querySelector('[data-testid="ErrorOutlineIcon"]') || - document.querySelector('.MuiSvgIcon-root'), - ).toBeTruthy(), + document.querySelector('[data-testid="CopyButton-error"]'), + ).toBeInTheDocument(), ); }); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx similarity index 74% rename from workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx rename to workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx index f034f9abce6..1c52720d334 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/components/CopyButton.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/agents/components/CopyButton.tsx @@ -40,34 +40,46 @@ const useStyles = makeStyles(theme => ({ }, })); +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 [copied, setCopied] = useState(false); - const [copyFailed, setCopyFailed] = useState(false); + const [state, setState] = useState('idle'); const handleCopy = () => { globalThis.navigator.clipboard .writeText(text) .then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + setState('copied'); + setTimeout(() => setState('idle'), 2000); }) .catch(() => { - setCopyFailed(true); - setTimeout(() => setCopyFailed(false), 2000); + setState('failed'); + setTimeout(() => setState('idle'), 2000); }); }; let tooltipTitle = t('copyButton.copy'); - if (copied) tooltipTitle = t('copyButton.copied'); - else if (copyFailed) tooltipTitle = t('copyButton.failed'); + if (state === 'copied') tooltipTitle = t('copyButton.copied'); + else if (state === 'failed') tooltipTitle = t('copyButton.failed'); let icon = ; - if (copied) icon = ; - else if (copyFailed) - icon = ; + if (state === 'copied') + icon = ( + + ); + else if (state === 'failed') + icon = ( + + ); 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/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/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/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..cee436e2789 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,42 @@ 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.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': '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 +278,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..ef245fc2f41 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,43 @@ 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.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': '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 +282,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..e13afdbf070 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,44 @@ 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.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': '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 +288,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..fd77d0b9c2e 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,42 @@ 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.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': '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 +280,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..d7dd92ef856 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,49 @@ 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.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': + '\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 +329,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..d346e4ca9d2 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,45 @@ 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', + lastHeartbeat: 'Last heartbeat', + }, + filter: { + healthLabel: 'Health status', + healthAll: 'All', + healthReady: 'Ready', + healthCongested: 'Congested', + healthUnavailable: 'Unavailable', }, 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: { @@ -249,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', @@ -313,18 +310,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',