From 2c3f09a66bf07c7187fc2a4371d73bbcf67e177c Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 11 Feb 2026 13:44:59 +0100 Subject: [PATCH 1/4] feat(x2a): add project and module status Signed-off-by: Marek Libra --- .../x2a/.changeset/fresh-bobcats-serve.md | 7 + workspaces/x2a/package.json | 4 +- .../x2a-backend/src/router/modules.test.ts | 71 ++++ .../plugins/x2a-backend/src/router/modules.ts | 52 +-- .../x2a-backend/src/schema/openapi.yaml | 81 ++++- .../openapi/generated/models/Module.model.ts | 2 + .../generated/models/ModuleStatus.model.ts | 24 ++ .../models/ModulesStatusSummary.model.ts | 49 +++ .../openapi/generated/models/Project.model.ts | 2 + .../generated/models/ProjectStatus.model.ts | 29 ++ .../models/ProjectStatusState.model.ts | 30 ++ .../schema/openapi/generated/models/index.ts | 4 + .../src/schema/openapi/generated/router.ts | 82 +++++ .../src/services/X2ADatabaseService/index.ts | 88 ++++- .../X2ADatabaseService/modules.test.ts | 77 +++++ .../X2ADatabaseService/status.test.ts | 323 ++++++++++++++++++ .../src/services/X2ADatabaseService/status.ts | 123 +++++++ .../openapi/generated/models/Module.model.ts | 2 + .../generated/models/ModuleStatus.model.ts | 24 ++ .../models/ModulesStatusSummary.model.ts | 49 +++ .../openapi/generated/models/Project.model.ts | 2 + .../generated/models/ProjectStatus.model.ts | 29 ++ .../models/ProjectStatusState.model.ts | 30 ++ .../schema/openapi/generated/models/index.ts | 4 + .../x2a/plugins/x2a-common/report.api.md | 28 ++ workspaces/x2a/plugins/x2a/package.json | 1 + workspaces/x2a/plugins/x2a/report.api.md | 49 ++- .../components/ModuleTable/ModuleTable.tsx | 21 +- .../ProjectList/ProjectStatusCell.tsx | 159 +++++++++ .../components/ProjectList/ProjectTable.tsx | 5 + .../x2a/plugins/x2a/src/translations/de.ts | 18 + .../x2a/plugins/x2a/src/translations/es.ts | 18 + .../x2a/plugins/x2a/src/translations/fr.ts | 18 + .../x2a/plugins/x2a/src/translations/it.ts | 18 + .../x2a/plugins/x2a/src/translations/ref.ts | 24 ++ workspaces/x2a/tsconfig.json | 2 +- 36 files changed, 1473 insertions(+), 76 deletions(-) create mode 100644 workspaces/x2a/.changeset/fresh-bobcats-serve.md create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatus.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatusState.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatus.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatusState.model.ts create mode 100644 workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectStatusCell.tsx diff --git a/workspaces/x2a/.changeset/fresh-bobcats-serve.md b/workspaces/x2a/.changeset/fresh-bobcats-serve.md new file mode 100644 index 00000000000..c2c34fb0ee1 --- /dev/null +++ b/workspaces/x2a/.changeset/fresh-bobcats-serve.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +Add module and project status. diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index bc2487f18ee..2606a78bbf3 100644 --- a/workspaces/x2a/package.json +++ b/workspaces/x2a/package.json @@ -10,13 +10,13 @@ "start": "NODE_OPTIONS=--no-node-snapshot ; backstage-cli repo start", "build:backend": "yarn workspace backend build", "build:all": "backstage-cli repo build --all", - "build:api-reports": "yarn build:api-reports:only --tsc", + "build:api-reports": "yarn tsc && yarn build:api-reports:only", "build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type,ae-undocumented --validate-release-tags --exclude client/src/schema/openapi/generated", "build:knip-reports": "backstage-repo-tools knip-reports", "build-image": "yarn workspace backend build-image", "openapi-generate": "cd plugins/x2a-backend && yarn openapi-generate", "tsc": "tsc", - "tsc:full": "tsc --skipLibCheck false --incremental false", + "tsc:full": "tsc --skipLibCheck true --incremental false", "clean": "backstage-cli repo clean", "test": "backstage-cli repo test", "test:all": "yarn openapi-generate && yarn prettier:check && yarn lint:all && backstage-cli repo test --coverage", diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts index 8a855a29f67..72a646da61d 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts @@ -59,6 +59,33 @@ describe('createRouter – modules', () => { LONG_TEST_TIMEOUT, ); + it.each(supportedDatabaseIds)( + 'should return each module with status field from service enrichment - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const x2aDatabase = X2ADatabaseService.create({ + logger: mockServices.logger.mock(), + dbClient: client, + }); + const app = await createApp(client); + const project = await createTestProject(x2aDatabase); + await createTestModule(x2aDatabase, project.id, { + name: 'Module A', + sourcePath: '/a', + }); + + const response = await request(app) + .get(`/projects/${project.id}/modules`) + .send(); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toHaveProperty('status'); + expect(response.body[0].status).toBe('pending'); + }, + LONG_TEST_TIMEOUT, + ); + it.each(supportedDatabaseIds)( 'should return 404 when project does not exist - %p', async databaseId => { @@ -357,5 +384,49 @@ describe('createRouter – modules', () => { ); }, ); + + it.each(supportedDatabaseIds)( + 'should accept optional aapCredentials and pass them to kubeService.createJob - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const x2aDatabase = X2ADatabaseService.create({ + logger: mockServices.logger.mock(), + dbClient: client, + }); + const project = await createTestProject(x2aDatabase); + const module = await createTestModule(x2aDatabase, project.id); + + const mockCreateJob = jest + .fn() + .mockResolvedValue({ k8sJobName: 'k8s-job' }); + const appWithMock = await createApp(client, undefined, undefined, { + createJob: mockCreateJob, + }); + + const aapCredentials = { + url: 'https://aap.example.com', + orgName: 'Default', + oauthToken: 'oauth-token', + }; + const response = await request(appWithMock) + .post(`/projects/${project.id}/modules/${module.id}/run`) + .send({ + ...runBody, + aapCredentials, + }); + + expect(response.status).toBe(200); + expect(mockCreateJob).toHaveBeenCalledTimes(1); + expect(mockCreateJob).toHaveBeenCalledWith( + expect.objectContaining({ + aapCredentials, + phase: 'analyze', + moduleId: module.id, + moduleName: module.name, + }), + ); + }, + LONG_TEST_TIMEOUT, + ); }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts index 7466aefe397..9298239bc69 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts @@ -18,14 +18,9 @@ import { z } from 'zod'; import express from 'express'; import { randomUUID } from 'node:crypto'; import { InputError, NotFoundError } from '@backstage/errors'; -import { Module } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import type { RouterDeps } from './types'; -import { - getUserRef, - reconcileJobStatus, - removeSensitiveFromJob, -} from './common'; +import { getUserRef, reconcileJobStatus } from './common'; export function registerModuleRoutes( router: express.Router, @@ -54,50 +49,7 @@ export function registerModuleRoutes( // List modules const modules = await x2aDatabase.listModules({ projectId }); - // TODO: This can be optimized by using a single query to list all jobs for all modules. - const lastAnalyzeJobsOfModules = await Promise.all( - modules.map(module => - x2aDatabase.listJobs({ - projectId, - moduleId: module.id, - phase: 'analyze', - lastJobOnly: true, - }), - ), - ); - const lastMigrateJobsOfModules = await Promise.all( - modules.map(module => - x2aDatabase.listJobs({ - projectId, - moduleId: module.id, - phase: 'migrate', - lastJobOnly: true, - }), - ), - ); - const lastPublishJobsOfModules = await Promise.all( - modules.map(module => - x2aDatabase.listJobs({ - projectId, - moduleId: module.id, - phase: 'publish', - lastJobOnly: true, - }), - ), - ); - - const response: Array = modules.map((module, idxModule) => { - return { - ...module, - analyze: removeSensitiveFromJob(lastAnalyzeJobsOfModules[idxModule][0]), - migrate: removeSensitiveFromJob(lastMigrateJobsOfModules[idxModule][0]), - publish: removeSensitiveFromJob(lastPublishJobsOfModules[idxModule][0]), - - // TODO: calculate module's status from the last job - }; - }); - - res.json(response); + res.json(modules); }); // TODO: This is a TEMPORARY endpoint for testing only. diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 3da4b926a2e..98689bdcf39 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -457,7 +457,6 @@ components: targetRepoBranch: description: Branch of the target repository type: string - # TODO: status as optional field createdAt: type: string format: date-time @@ -468,6 +467,9 @@ components: migrationPlan: $ref: '#/components/schemas/Artifact' description: Project migration plan artifact (by init phase) + status: + $ref: '#/components/schemas/ProjectStatus' + description: Project status calculated from the status of its modules required: - id - name @@ -501,7 +503,8 @@ components: $ref: '#/components/schemas/Job' publish: $ref: '#/components/schemas/Job' - # TODO: module status + status: + $ref: '#/components/schemas/ModuleStatus' required: - id - name @@ -516,6 +519,80 @@ components: - success - error + ModuleStatus: + type: string + description: | + Module status is the status of the last job of its last phase. + If a later retrigger for an earlier phase fails (e.g. when retrigger on analyze + fails but a former migrate already passed), the modules status should not change (is still based on the last phase). + The pending state is used for modules that are scheduled for execution but not yet actually running. If a module + is in pending state for long time, it can refer to an issue with the OCP setup. + enum: + - pending + - running + - success + - error + + ProjectStatusState: + type: string + description: | + Project status state. + It is calculated from the status of its modules. + - created: Project is created but not yet initialized + - initializing: Project's init job is running + - initialized: Project's init job finished successfully, pending next phase execution + - inProgress: A non-init phase is running + - completed: All modules are in success state + - failed: At least one module is in error state + enum: + - created + - initializing + - initialized + - inProgress + - completed + - failed + + ModulesStatusSummary: + type: object + properties: + total: + type: integer + description: Total number of modules in the project + finished: + type: integer + description: Number of modules in success state of the publish phase (no more work is needed) + waiting: + type: integer + description: Number of modules in success state of a non-publish phase (means waiting for human interaction) + pending: + type: integer + description: Number of modules in pending state (scheduled for execution but not actually running) + running: + type: integer + description: Number of modules in running state (actually running) + error: + type: integer + description: Number of modules in error state (execution is over but failed) + required: + - total + - finished + - waiting + - completed + - pending + - running + - error + + ProjectStatus: + type: object + properties: + state: + $ref: '#/components/schemas/ProjectStatusState' + modulesSummary: + $ref: '#/components/schemas/ModulesStatusSummary' + required: + - state + - modulesSummary + Job: type: object required: diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts index 3cf9de2dc50..1a723545b71 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts @@ -18,6 +18,7 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Job } from '../models/Job.model'; +import { ModuleStatus } from '../models/ModuleStatus.model'; /** * @public @@ -42,4 +43,5 @@ export interface Module { analyze?: Job; migrate?: Job; publish?: Job; + status?: ModuleStatus; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts new file mode 100644 index 00000000000..e6da640cea4 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type ModuleStatus = 'pending' | 'running' | 'success' | 'error'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts new file mode 100644 index 00000000000..6d16f4643e8 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ModulesStatusSummary { + /** + * Total number of modules in the project + */ + total: number; + /** + * Number of modules in success state of the publish phase (no more work is needed) + */ + finished: number; + /** + * Number of modules in success state of a non-publish phase (means waiting for human interaction) + */ + waiting: number; + /** + * Number of modules in pending state (scheduled for execution but not actually running) + */ + pending: number; + /** + * Number of modules in running state (actually running) + */ + running: number; + /** + * Number of modules in error state (execution is over but failed) + */ + error: number; +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts index c156ff76241..6e070ba3645 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts @@ -18,6 +18,7 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Artifact } from '../models/Artifact.model'; +import { ProjectStatus } from '../models/ProjectStatus.model'; /** * @public @@ -64,4 +65,5 @@ export interface Project { */ createdBy: string; migrationPlan?: Artifact; + status?: ProjectStatus; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatus.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatus.model.ts new file mode 100644 index 00000000000..d7b0d5665dc --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatus.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { ModulesStatusSummary } from '../models/ModulesStatusSummary.model'; +import { ProjectStatusState } from '../models/ProjectStatusState.model'; + +/** + * @public + */ +export interface ProjectStatus { + state: ProjectStatusState; + modulesSummary: ModulesStatusSummary; +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatusState.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatusState.model.ts new file mode 100644 index 00000000000..63638d30e72 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectStatusState.model.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type ProjectStatusState = + | 'created' + | 'initializing' + | 'initialized' + | 'inProgress' + | 'completed' + | 'failed'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts index 182be79bb9b..aaade911c2a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts @@ -24,7 +24,11 @@ export * from '../models/JobStatusEnum.model'; export * from '../models/MigrationPhase.model'; export * from '../models/Module.model'; export * from '../models/ModulePhase.model'; +export * from '../models/ModuleStatus.model'; +export * from '../models/ModulesStatusSummary.model'; export * from '../models/Project.model'; +export * from '../models/ProjectStatus.model'; +export * from '../models/ProjectStatusState.model'; export * from '../models/ProjectsGet200Response.model'; export * from '../models/ProjectsPostRequest.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 40bf2567963..e09728373f7 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -700,6 +700,10 @@ export const spec = { "migrationPlan": { "$ref": "#/components/schemas/Artifact", "description": "Project migration plan artifact (by init phase)" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus", + "description": "Project status calculated from the status of its modules" } }, "required": [ @@ -741,6 +745,9 @@ export const spec = { }, "publish": { "$ref": "#/components/schemas/Job" + }, + "status": { + "$ref": "#/components/schemas/ModuleStatus" } }, "required": [ @@ -759,6 +766,81 @@ export const spec = { "error" ] }, + "ModuleStatus": { + "type": "string", + "description": "Module status is the status of the last job of its last phase.\nIf a later retrigger for an earlier phase fails (e.g. when retrigger on analyze\nfails but a former migrate already passed), the modules status should not change (is still based on the last phase).\nThe pending state is used for modules that are scheduled for execution but not yet actually running. If a module\nis in pending state for long time, it can refer to an issue with the OCP setup.\n", + "enum": [ + "pending", + "running", + "success", + "error" + ] + }, + "ProjectStatusState": { + "type": "string", + "description": "Project status state.\nIt is calculated from the status of its modules.\n- created: Project is created but not yet initialized\n- initializing: Project's init job is running\n- initialized: Project's init job finished successfully, pending next phase execution\n- inProgress: A non-init phase is running\n- completed: All modules are in success state\n- failed: At least one module is in error state\n", + "enum": [ + "created", + "initializing", + "initialized", + "inProgress", + "completed", + "failed" + ] + }, + "ModulesStatusSummary": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "Total number of modules in the project" + }, + "finished": { + "type": "integer", + "description": "Number of modules in success state of the publish phase (no more work is needed)" + }, + "waiting": { + "type": "integer", + "description": "Number of modules in success state of a non-publish phase (means waiting for human interaction)" + }, + "pending": { + "type": "integer", + "description": "Number of modules in pending state (scheduled for execution but not actually running)" + }, + "running": { + "type": "integer", + "description": "Number of modules in running state (actually running)" + }, + "error": { + "type": "integer", + "description": "Number of modules in error state (execution is over but failed)" + } + }, + "required": [ + "total", + "finished", + "waiting", + "completed", + "pending", + "running", + "error" + ] + }, + "ProjectStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/ProjectStatusState" + }, + "modulesSummary": { + "$ref": "#/components/schemas/ModulesStatusSummary" + } + }, + "required": [ + "state", + "modulesSummary" + ] + }, "Job": { "type": "object", "required": [ diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index ac45a9869cf..3001f974660 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -39,6 +39,8 @@ import { ProjectsGet } from '../../schema/openapi'; import { JobOperations, CreateJobInput } from './jobOperations'; import { ModuleOperations } from './moduleOperations'; import { ProjectOperations } from './projectOperations'; +import { removeSensitiveFromJob } from '../../router/common'; +import { calculateModuleStatus, calculateProjectStatus } from './status'; export class X2ADatabaseService { readonly #logger: LoggerService; @@ -71,6 +73,24 @@ export class X2ADatabaseService { ); } + /** + * Enriches a project with migration plan and status (used by listProjects and getProject). + */ + private async enrichProject(project: Project): Promise { + const projectId = project.id; + project.migrationPlan = await this.getMigrationPlanForProject(projectId); + + const initJob = await this.listJobs({ + projectId, + phase: 'init', + lastJobOnly: true, + }); + project.status = calculateProjectStatus( + await this.listModules({ projectId }), + initJob[0], + ); + } + // Projects (facade enriches basic objects when needed) async createProject( @@ -102,13 +122,7 @@ export class X2ADatabaseService { this.#logger.info( `this.#projectOps.listProjects finished, adding migration plans to projects`, ); - await Promise.all( - result.projects.map(async project => { - project.migrationPlan = await this.getMigrationPlanForProject( - project.id, - ); - }), - ); + await Promise.all(result.projects.map(p => this.enrichProject(p))); return result; } @@ -121,10 +135,11 @@ export class X2ADatabaseService { ): Promise { const project = await this.#projectOps.getProject({ projectId }, options); if (!project) return undefined; - this.#logger.info( - `this.#projectOps.getProject finished, adding migration plan to project`, + + this.#logger.debug( + `this.#projectOps.getProject finished, adding migration plan and status to project`, ); - project.migrationPlan = await this.getMigrationPlanForProject(project.id); + await this.enrichProject(project); return project; } @@ -153,7 +168,58 @@ export class X2ADatabaseService { } async listModules({ projectId }: { projectId: string }): Promise { - return this.#moduleOps.listModules({ projectId }); + const modules = await this.#moduleOps.listModules({ projectId }); + // TODO: This can be optimized by using a single query to list all jobs for all modules. + const lastAnalyzeJobsOfModules = await Promise.all( + modules.map(module => + this.listJobs({ + projectId, + moduleId: module.id, + phase: 'analyze', + lastJobOnly: true, + }), + ), + ); + const lastMigrateJobsOfModules = await Promise.all( + modules.map(module => + this.listJobs({ + projectId, + moduleId: module.id, + phase: 'migrate', + lastJobOnly: true, + }), + ), + ); + const lastPublishJobsOfModules = await Promise.all( + modules.map(module => + this.listJobs({ + projectId, + moduleId: module.id, + phase: 'publish', + lastJobOnly: true, + }), + ), + ); + + const response: Array = modules.map((module, idxModule) => { + const analyze = removeSensitiveFromJob( + lastAnalyzeJobsOfModules[idxModule][0], + ); + const migrate = removeSensitiveFromJob( + lastMigrateJobsOfModules[idxModule][0], + ); + const publish = removeSensitiveFromJob( + lastPublishJobsOfModules[idxModule][0], + ); + const lastJobs = { analyze, migrate, publish }; + return { + ...module, + ...lastJobs, + status: calculateModuleStatus(lastJobs), + }; + }); + + return response; } async deleteModule({ id }: { id: string }): Promise { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts index 57b186da45a..10778a9dda1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts @@ -336,6 +336,83 @@ describe('X2ADatabaseService – modules', () => { expect(project1Modules.map(m => m.id)).toContain(module1Project1.id); }, ); + + it.each(supportedDatabaseIds)( + 'returns each module with status and optional analyze, migrate, publish - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Test Project', + abbreviation: 'TP', + description: 'D', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + await service.createModule({ + name: 'Module A', + sourcePath: '/a', + projectId: project.id, + }); + + const modules = await service.listModules({ projectId: project.id }); + + expect(modules).toHaveLength(1); + expect(modules[0]).toHaveProperty('status'); + expect(modules[0].status).toBe('pending'); + expect(modules[0].id).toBeDefined(); + expect(modules[0].name).toBe('Module A'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'enriches each module with last analyze, migrate, publish when jobs exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Test Project', + abbreviation: 'TP', + description: 'D', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const mod = await service.createModule({ + name: 'Module With Jobs', + sourcePath: '/with-jobs', + projectId: project.id, + }); + const analyzeJob = await service.createJob({ + projectId: project.id, + moduleId: mod.id, + phase: 'analyze', + status: 'pending', + callbackToken: 'tk', + }); + await service.updateJob({ + id: analyzeJob.id, + status: 'success', + }); + + const modules = await service.listModules({ projectId: project.id }); + + expect(modules).toHaveLength(1); + expect(modules[0].analyze).toBeDefined(); + expect(modules[0].analyze?.id).toBe(analyzeJob.id); + expect(modules[0].analyze?.phase).toBe('analyze'); + expect(modules[0].analyze).not.toHaveProperty('callbackToken'); + expect(modules[0].migrate).toBeUndefined(); + expect(modules[0].publish).toBeUndefined(); + }, + LONG_TEST_TIMEOUT, + ); }); describe('deleteModule', () => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts new file mode 100644 index 00000000000..c5864f31c52 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts @@ -0,0 +1,323 @@ +/* + * 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 { + Job, + Module, + ModuleStatus, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import { calculateModuleStatus, calculateProjectStatus } from './status'; + +function job(status: Job['status']): Job { + return { + id: 'job-id', + projectId: 'project-id', + startedAt: new Date(), + phase: 'analyze', + k8sJobName: 'k8s-job', + status, + }; +} + +describe('calculateModuleStatus', () => { + it('returns pending when no jobs are provided', () => { + expect(calculateModuleStatus({})).toBe('pending'); + }); + + it('returns analyze status when only analyze job is provided', () => { + expect(calculateModuleStatus({ analyze: job('pending') })).toBe('pending'); + expect(calculateModuleStatus({ analyze: job('running') })).toBe('running'); + expect(calculateModuleStatus({ analyze: job('success') })).toBe('success'); + expect(calculateModuleStatus({ analyze: job('error') })).toBe('error'); + }); + + it('returns migrate status when only migrate job is provided', () => { + expect(calculateModuleStatus({ migrate: job('success') })).toBe('success'); + expect(calculateModuleStatus({ migrate: job('error') })).toBe('error'); + }); + + it('returns publish status when only publish job is provided', () => { + expect(calculateModuleStatus({ publish: job('success') })).toBe('success'); + expect(calculateModuleStatus({ publish: job('running') })).toBe('running'); + }); + + it('prefers migrate over analyze when both are provided', () => { + expect( + calculateModuleStatus({ + analyze: job('success'), + migrate: job('error'), + }), + ).toBe('error'); + expect( + calculateModuleStatus({ + analyze: job('error'), + migrate: job('success'), + }), + ).toBe('success'); + }); + + it('prefers publish over migrate and analyze when multiple are provided', () => { + expect( + calculateModuleStatus({ + analyze: job('success'), + migrate: job('success'), + publish: job('error'), + }), + ).toBe('error'); + expect( + calculateModuleStatus({ + analyze: job('error'), + migrate: job('error'), + publish: job('success'), + }), + ).toBe('success'); + }); + + it('uses last phase status so retrigger of earlier phase does not change module status', () => { + // When publish already passed, a later retrigger on analyze that fails + // should not change the module status (still success from publish). + expect( + calculateModuleStatus({ + analyze: job('error'), + migrate: job('success'), + publish: job('success'), + }), + ).toBe('success'); + }); +}); + +/** Minimal module for project status tests; only status and publish are used by calculateProjectStatus. */ +function module( + status: ModuleStatus, + options?: { publishStatus?: Job['status'] }, +): Module { + const m: Module = { + id: 'mod-id', + name: 'Module', + sourcePath: '/path', + projectId: 'project-id', + status, + }; + if (options?.publishStatus !== undefined) { + m.publish = job(options.publishStatus); + } + return m; +} + +function initJob(status: Job['status']): Job { + return job(status); +} + +describe('calculateProjectStatus', () => { + describe('state: created', () => { + it('returns created when no init job and no modules', () => { + const result = calculateProjectStatus([], undefined); + expect(result.state).toBe('created'); + expect(result.modulesSummary).toEqual({ + total: 0, + finished: 0, + waiting: 0, + pending: 0, + running: 0, + error: 0, + }); + }); + }); + + describe('state: failed', () => { + it('returns failed when at least one module has status error', () => { + const result = calculateProjectStatus([ + module('success', { publishStatus: 'success' }), + module('error'), + ]); + expect(result.state).toBe('failed'); + expect(result.modulesSummary.error).toBe(1); + }); + + it('returns failed when no init job is provided (init never ran)', () => { + const result = calculateProjectStatus([ + module('pending'), + module('pending'), + ]); + expect(result.state).toBe('failed'); + }); + + it('returns failed when init job completed with error', () => { + const result = calculateProjectStatus( + [module('pending')], + initJob('error'), + ); + expect(result.state).toBe('failed'); + }); + + it('returns failed even when init succeeded if any module is in error', () => { + const result = calculateProjectStatus( + [module('success', { publishStatus: 'success' }), module('error')], + initJob('success'), + ); + expect(result.state).toBe('failed'); + expect(result.modulesSummary.error).toBe(1); + }); + }); + + describe('state: initializing', () => { + it('returns initializing when init job is pending', () => { + const result = calculateProjectStatus( + [module('pending'), module('pending')], + initJob('pending'), + ); + expect(result.state).toBe('initializing'); + }); + + it('returns initializing when init job is running', () => { + const result = calculateProjectStatus( + [module('pending')], + initJob('running'), + ); + expect(result.state).toBe('initializing'); + }); + }); + + describe('state: inProgress', () => { + it('returns inProgress when init succeeded but not all modules have finished publish', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + module('success'), // no publish yet – waiting + ], + initJob('success'), + ); + expect(result.state).toBe('inProgress'); + expect(result.modulesSummary.finished).toBe(1); + expect(result.modulesSummary.total).toBe(2); + }); + + it('returns inProgress when init succeeded and no module has finished publish', () => { + const result = calculateProjectStatus( + [module('pending'), module('running')], + initJob('success'), + ); + expect(result.state).toBe('inProgress'); + expect(result.modulesSummary.finished).toBe(0); + }); + }); + + describe('state: completed', () => { + it('returns completed when init succeeded and there are no modules', () => { + const result = calculateProjectStatus([], initJob('success')); + expect(result.state).toBe('completed'); + expect(result.modulesSummary.finished).toBe(0); + expect(result.modulesSummary.total).toBe(0); + }); + + it('returns completed when init succeeded and every module has finished publish', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + module('success', { publishStatus: 'success' }), + ], + initJob('success'), + ); + expect(result.state).toBe('completed'); + expect(result.modulesSummary.finished).toBe(2); + expect(result.modulesSummary.total).toBe(2); + }); + + it('returns completed when init succeeded and single module has finished publish', () => { + const result = calculateProjectStatus( + [module('success', { publishStatus: 'success' })], + initJob('success'), + ); + expect(result.state).toBe('completed'); + expect(result.modulesSummary.finished).toBe(1); + expect(result.modulesSummary.total).toBe(1); + }); + }); + + describe('modulesSummary counts', () => { + it('counts total as number of project modules', () => { + const result = calculateProjectStatus( + [module('pending'), module('pending'), module('pending')], + initJob('pending'), + ); + expect(result.modulesSummary.total).toBe(3); + }); + + it('counts finished as modules with success status and publish phase success', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + module('success'), // no publish + module('success', { publishStatus: 'success' }), + ], + initJob('success'), + ); + expect(result.modulesSummary.finished).toBe(2); + }); + + it('counts waiting as modules with success status but no publish job', () => { + const result = calculateProjectStatus( + [ + module('success'), // success but no publish → waiting + module('success', { publishStatus: 'success' }), // finished + ], + initJob('success'), + ); + expect(result.modulesSummary.waiting).toBe(1); + expect(result.modulesSummary.finished).toBe(1); + }); + + it('counts pending as modules with status pending', () => { + const result = calculateProjectStatus( + [module('pending'), module('pending'), module('success')], + initJob('success'), + ); + expect(result.modulesSummary.pending).toBe(2); + }); + + it('counts running as modules with status running', () => { + const result = calculateProjectStatus( + [module('running'), module('pending')], + initJob('success'), + ); + expect(result.modulesSummary.running).toBe(1); + expect(result.modulesSummary.pending).toBe(1); + }); + + it('counts error as modules with status error', () => { + const result = calculateProjectStatus([ + module('error'), + module('success'), + module('error'), + ]); + expect(result.modulesSummary.error).toBe(2); + expect(result.modulesSummary.total).toBe(3); + }); + + it('returns zero counts for empty module list', () => { + const result = calculateProjectStatus([], initJob('success')); + expect(result.modulesSummary).toEqual({ + total: 0, + finished: 0, + waiting: 0, + pending: 0, + running: 0, + error: 0, + }); + }); + }); +}); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts new file mode 100644 index 00000000000..ef6df516f77 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts @@ -0,0 +1,123 @@ +/* + * 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 { + Job, + Module, + ModuleStatus, + ProjectStatus, + ProjectStatusState, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +/** + * Module's status is the status of the last job of its last phase. + * + * If a later retrigger for an earlier phase fails (e.g. when retrigger on analyze + * fails but a former migrate already passed), the modules status should not change. + */ +export function calculateModuleStatus({ + analyze, + migrate, + publish, +}: { + analyze?: Job; + migrate?: Job; + publish?: Job; +}): ModuleStatus { + if (publish) { + return publish.status; + } + if (migrate) { + return migrate.status; + } + if (analyze) { + return analyze.status; + } + + return 'pending'; +} + +/** + * Project status is calculated from its modules. + * + * Its "state" is accompanied by summary of its modules statuses. + */ +export function calculateProjectStatus( + projectModules: Module[], + initJob?: Job, +): ProjectStatus { + const total = projectModules.length; + if (!initJob && total === 0) { + return { + state: 'created', + modulesSummary: { + total: 0, + finished: 0, + waiting: 0, + pending: 0, + running: 0, + error: 0, + }, + }; + } + + const error = projectModules.filter( + module => module.status === 'error', + ).length; + const finished = projectModules.filter( + module => + module.status === 'success' && module.publish?.status === 'success', + ).length; + const waiting = projectModules.filter( + module => module.status === 'success' && !module.publish, + ).length; + const pending = projectModules.filter( + module => module.status === 'pending', + ).length; + const running = projectModules.filter( + module => module.status === 'running', + ).length; + + let state: ProjectStatusState; + if (error > 0) { + state = 'failed'; + } else if (['pending', 'running'].includes(initJob?.status ?? '')) { + state = 'initializing'; + } else { + if (initJob?.status === 'success') { + state = 'initialized'; + if (finished === total) { + state = 'completed'; + } else { + state = 'inProgress'; + } + } else { + state = 'failed'; + } + } + + return { + state: state, + modulesSummary: { + total, + finished, + waiting, + pending, + running, + error, + }, + }; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts index 3cf9de2dc50..1a723545b71 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts @@ -18,6 +18,7 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Job } from '../models/Job.model'; +import { ModuleStatus } from '../models/ModuleStatus.model'; /** * @public @@ -42,4 +43,5 @@ export interface Module { analyze?: Job; migrate?: Job; publish?: Job; + status?: ModuleStatus; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts new file mode 100644 index 00000000000..e6da640cea4 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type ModuleStatus = 'pending' | 'running' | 'success' | 'error'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts new file mode 100644 index 00000000000..6d16f4643e8 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModulesStatusSummary.model.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ModulesStatusSummary { + /** + * Total number of modules in the project + */ + total: number; + /** + * Number of modules in success state of the publish phase (no more work is needed) + */ + finished: number; + /** + * Number of modules in success state of a non-publish phase (means waiting for human interaction) + */ + waiting: number; + /** + * Number of modules in pending state (scheduled for execution but not actually running) + */ + pending: number; + /** + * Number of modules in running state (actually running) + */ + running: number; + /** + * Number of modules in error state (execution is over but failed) + */ + error: number; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts index c156ff76241..6e070ba3645 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts @@ -18,6 +18,7 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Artifact } from '../models/Artifact.model'; +import { ProjectStatus } from '../models/ProjectStatus.model'; /** * @public @@ -64,4 +65,5 @@ export interface Project { */ createdBy: string; migrationPlan?: Artifact; + status?: ProjectStatus; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatus.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatus.model.ts new file mode 100644 index 00000000000..d7b0d5665dc --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatus.model.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { ModulesStatusSummary } from '../models/ModulesStatusSummary.model'; +import { ProjectStatusState } from '../models/ProjectStatusState.model'; + +/** + * @public + */ +export interface ProjectStatus { + state: ProjectStatusState; + modulesSummary: ModulesStatusSummary; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatusState.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatusState.model.ts new file mode 100644 index 00000000000..63638d30e72 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectStatusState.model.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type ProjectStatusState = + | 'created' + | 'initializing' + | 'initialized' + | 'inProgress' + | 'completed' + | 'failed'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts index 182be79bb9b..aaade911c2a 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts @@ -24,7 +24,11 @@ export * from '../models/JobStatusEnum.model'; export * from '../models/MigrationPhase.model'; export * from '../models/Module.model'; export * from '../models/ModulePhase.model'; +export * from '../models/ModuleStatus.model'; +export * from '../models/ModulesStatusSummary.model'; export * from '../models/Project.model'; +export * from '../models/ProjectStatus.model'; +export * from '../models/ProjectStatusState.model'; export * from '../models/ProjectsGet200Response.model'; export * from '../models/ProjectsPostRequest.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 3c24e3e5ca1..9f21a112077 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -115,11 +115,26 @@ export interface Module { // (undocumented) publish?: Job; sourcePath: string; + // (undocumented) + status?: ModuleStatus; } // @public (undocumented) export type ModulePhase = 'analyze' | 'migrate' | 'publish'; +// @public (undocumented) +export interface ModulesStatusSummary { + error: number; + finished: number; + pending: number; + running: number; + total: number; + waiting: number; +} + +// @public (undocumented) +export type ModuleStatus = 'pending' | 'running' | 'success' | 'error'; + // @public export function normalizeRepoUrl(url: string): string; @@ -135,6 +150,8 @@ export interface Project { name: string; sourceRepoBranch: string; sourceRepoUrl: string; + // (undocumented) + status?: ProjectStatus; targetRepoBranch: string; targetRepoUrl: string; } @@ -302,6 +319,17 @@ export interface ProjectsProjectIdRunPostRequest { userPrompt?: string; } +// @public (undocumented) +export interface ProjectStatus { + // (undocumented) + modulesSummary: ModulesStatusSummary; + // (undocumented) + state: ProjectStatusState; +} + +// @public (undocumented) +export type ProjectStatusState = 'created' | 'initializing' | 'initialized' | 'inProgress' | 'completed' | 'failed'; + // @public export interface RequestOptions { // (undocumented) diff --git a/workspaces/x2a/plugins/x2a/package.json b/workspaces/x2a/plugins/x2a/package.json index 7a443031adf..8e2590c82ee 100644 --- a/workspaces/x2a/plugins/x2a/package.json +++ b/workspaces/x2a/plugins/x2a/package.json @@ -43,6 +43,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@mui/material": "^7.3.7", + "@mui/x-charts": "^8.27.0", "@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*", "react-use": "^17.2.4" }, diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index 81a2ebf765a..0ad05a977f5 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -26,6 +26,7 @@ root: RouteRef; // @public export const x2aPluginTranslationRef: TranslationRef<"plugin.x2a", { +<<<<<<< HEAD readonly "module.name": string; readonly "module.status": string; readonly "module.actions.runNextPhase": string; @@ -42,28 +43,62 @@ readonly "module.finishedAt": string; readonly "page.title": string; readonly "page.subtitle": string; readonly "page.devTitle": string; +======= +>>>>>>> 33376cb33b (feat(x2a): add project and module status) readonly "table.columns.name": string; readonly "table.columns.description": string; -readonly "table.columns.status": string; -readonly "table.columns.createdAt": string; readonly "table.columns.abbreviation": string; +readonly "table.columns.status": string; readonly "table.columns.sourceRepo": string; readonly "table.columns.targetRepo": string; +readonly "table.columns.createdAt": string; readonly "table.actions.deleteProject": string; readonly "table.detailPanel": string; readonly "table.projectsCount": string; -readonly "common.newProject": string; +readonly "page.title": string; +readonly "page.subtitle": string; +readonly "page.devTitle": string; +readonly "sidebar.x2a.title": string; readonly "project.id": string; readonly "project.description": string; readonly "project.abbreviation": string; readonly "project.createdBy": string; +readonly "project.statuses.none": string; +readonly "project.statuses.created": string; +readonly "project.statuses.initializing": string; +readonly "project.statuses.initialized": string; +readonly "project.statuses.inProgress": string; +readonly "project.statuses.completed": string; +readonly "project.statuses.failed": string; +readonly "common.newProject": string; +readonly "wizard.cancel": string; +readonly "wizard.back": string; +readonly "wizard.next": string; +readonly "module.summary.running": string; +readonly "module.summary.total": string; +readonly "module.summary.error": string; +readonly "module.summary.finished": string; +readonly "module.summary.waiting": string; +readonly "module.summary.pending": string; +readonly "module.name": string; +readonly "module.status": string; +readonly "module.actions.runNextPhase": string; +readonly "module.statuses.none": string; +readonly "module.statuses.running": string; +readonly "module.statuses.error": string; +readonly "module.statuses.pending": string; +readonly "module.statuses.success": string; +readonly "module.phases.none": string; +readonly "module.phases.init": string; +readonly "module.phases.analyze": string; +readonly "module.phases.migrate": string; +readonly "module.phases.publish": string; +readonly "module.lastPhase": string; +readonly "module.sourcePath": string; +readonly "module.artifacts": string; readonly "artifact.types.migration_plan": string; readonly "artifact.types.module_migration_plan": string; readonly "artifact.types.migrated_sources": string; -readonly "sidebar.x2a.title": string; -readonly "wizard.cancel": string; -readonly "wizard.next": string; -readonly "wizard.back": string; }>; // @public diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx index 9e3e9314e35..cd4b65cff11 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx @@ -87,6 +87,13 @@ const useColumns = ({ [targetRepoUrl], ); + const statusCell = useCallback( + (rowData: Module) => { + return
{t(`module.statuses.${rowData.status || 'none'}`)}
; + }, + [t], + ); + const startedAtCell = useCallback( (rowData: Module) => { const lastJob = getLastJob(rowData); @@ -110,17 +117,25 @@ const useColumns = ({ [t], ); - return useMemo(() => { + return useMemo((): TableColumn[] => { return [ { field: 'name', title: t('module.name') }, - { field: 'status', title: t('module.status') }, + // TODO: errorDetail + { field: 'status', render: statusCell, title: t('module.status') }, { field: 'sourcePath', title: t('module.sourcePath') }, { render: lastPhaseCell, title: t('module.lastPhase') }, { render: artifactsCell, title: t('module.artifacts') }, { render: startedAtCell, title: t('module.startedAt') }, { render: finishedAtCell, title: t('module.finishedAt') }, ]; - }, [t, lastPhaseCell, artifactsCell, startedAtCell, finishedAtCell]); + }, [ + t, + lastPhaseCell, + artifactsCell, + statusCell, + startedAtCell, + finishedAtCell, + ]); }; const canRunNextPhase = ({ module }: { module: Module }) => { diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectStatusCell.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectStatusCell.tsx new file mode 100644 index 00000000000..a5230bba7bd --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectStatusCell.tsx @@ -0,0 +1,159 @@ +/* + * 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 { ProjectStatus } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import { PieChart, PieValueType } from '@mui/x-charts'; +import { Grid, makeStyles, Tooltip } from '@material-ui/core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +const size = 25; + +const styles = makeStyles({ + tooltip: { + width: 100, + }, +}); + +const TooltipItem = ({ + label, + value, +}: { + label: string | React.ReactNode; + value: number; +}) => { + return ( + <> + + {label} + + + {value} + + + ); +}; + +export const ProjectStatusCell = ({ + projectStatus, +}: { + projectStatus?: ProjectStatus; +}) => { + const { t } = useTranslation(); + const classes = styles(); + const [open, setOpen] = useState(false); + + if (!projectStatus) { + return undefined; + } + + const modulesSummary = projectStatus.modulesSummary; + + let data: PieValueType[] = []; + let tooltipContent = <>; + if (modulesSummary) { + data = [ + { + label: t('module.summary.finished'), + value: modulesSummary.finished, + color: '#00C49F', + }, + { + label: t('module.summary.waiting'), + value: modulesSummary.waiting, + color: '#FFBB28', + }, + { + label: t('module.summary.pending'), + value: modulesSummary.pending, + color: '#FF8042', + }, + { + label: t('module.summary.running'), + value: modulesSummary.running, + color: '#FF8042', + }, + { + label: t('module.summary.error'), + value: modulesSummary.error, + color: '#FF0000', + }, + ]; + + tooltipContent = ( + + {t('module.summary.total')}} + value={modulesSummary.total} + /> + + + + + + + ); + } + + return ( + + {modulesSummary && ( + + setOpen(false)} + leaveDelay={1000} + placement="bottom" + arrow + title={tooltipContent} + > + { + event.stopPropagation(); + setOpen(!open); + }} + /> + + + )} + + + {t(`project.statuses.${projectStatus.state || 'none'}`)} + + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.tsx index c12bcc2370c..3d3b050a616 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectTable.tsx @@ -35,6 +35,7 @@ import { useTranslation } from '../../hooks/useTranslation'; import { Repository } from './Repository'; import { OrderDirection } from './types'; import { DetailPanel } from './DetailPanel'; +import { ProjectStatusCell } from './ProjectStatusCell'; type ProjectTableProps = { forceRefresh: () => void; @@ -83,6 +84,7 @@ const useColumns = ( orderDirection: OrderDirection, ): TableColumn[] => { const { t } = useTranslation(); + return useMemo(() => { const getDefaultSort = (index: number): OrderDirection => { if (index === orderBy) { @@ -101,6 +103,9 @@ const useColumns = ( { title: t('table.columns.status'), field: 'status', + render: (rowData: Project) => ( + + ), defaultSort: getDefaultSort(1), }, { diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index 48378d59f2a..f5e233111c9 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -43,6 +43,13 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'project.id': 'ID', 'project.abbreviation': 'Abkürzung', 'project.createdBy': 'Erstellt von', + 'project.statuses.none': '-', + 'project.statuses.created': 'Erstellt', + 'project.statuses.initializing': 'Wird initialisiert', + 'project.statuses.initialized': 'Initialisiert', + 'project.statuses.inProgress': 'In Bearbeitung', + 'project.statuses.completed': 'Abgeschlossen', + 'project.statuses.failed': 'Fehlgeschlagen', 'common.newProject': 'Neues Projekt', 'wizard.cancel': 'Abbrechen', 'wizard.back': 'Zurück', @@ -52,6 +59,12 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.phases.analyze': 'Analysieren', 'module.phases.migrate': 'Migrieren', 'module.phases.publish': 'Veröffentlichen', + 'module.summary.total': 'Gesamt', + 'module.summary.finished': 'Abgeschlossen', + 'module.summary.waiting': 'Wartend', + 'module.summary.pending': 'Ausstehend', + 'module.summary.running': 'Läuft', + 'module.summary.error': 'Fehler', 'module.actions.runNextPhase': 'Nächste Phase ausführen', 'module.lastPhase': 'Letzte Phase', 'module.name': 'Name', @@ -62,6 +75,11 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.finishedAt': 'Beendet am', 'artifact.types.migration_plan': 'Migrationsplan', 'artifact.types.module_migration_plan': 'Modulplan', + 'module.statuses.none': '-', + 'module.statuses.pending': 'Ausstehend', + 'module.statuses.running': 'Läuft', + 'module.statuses.success': 'Erfolg', + 'module.statuses.error': 'Fehler', 'artifact.types.migrated_sources': 'Migrierte Quellen', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index 3a1f5ec0f2d..df77023ad9f 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -43,6 +43,13 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'project.id': 'ID', 'project.abbreviation': 'Abreviatura', 'project.createdBy': 'Creado por', + 'project.statuses.none': '-', + 'project.statuses.created': 'Creado', + 'project.statuses.initializing': 'Inicializando', + 'project.statuses.initialized': 'Inicializado', + 'project.statuses.inProgress': 'En curso', + 'project.statuses.completed': 'Completado', + 'project.statuses.failed': 'Fallido', 'common.newProject': 'Nuevo proyecto', 'wizard.cancel': 'Cancelar', 'wizard.back': 'Atrás', @@ -52,6 +59,12 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.phases.analyze': 'Analizar', 'module.phases.migrate': 'Migrar', 'module.phases.publish': 'Publicar', + 'module.summary.total': 'Total', + 'module.summary.finished': 'Finalizado', + 'module.summary.waiting': 'En espera', + 'module.summary.pending': 'Pendiente', + 'module.summary.running': 'En ejecución', + 'module.summary.error': 'Error', 'module.actions.runNextPhase': 'Ejecutar siguiente fase', 'module.lastPhase': 'Última fase', 'module.name': 'Nombre', @@ -62,6 +75,11 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.finishedAt': 'Finalizado el', 'artifact.types.migration_plan': 'Plan de migración', 'artifact.types.module_migration_plan': 'Plan del módulo', + 'module.statuses.none': '-', + 'module.statuses.pending': 'Pendiente', + 'module.statuses.running': 'En ejecución', + 'module.statuses.success': 'Éxito', + 'module.statuses.error': 'Error', 'artifact.types.migrated_sources': 'Fuentes migradas', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index abea9d47562..3f3d273785a 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -43,6 +43,13 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'project.id': 'ID', 'project.abbreviation': 'Abréviation', 'project.createdBy': 'Créé par', + 'project.statuses.none': '-', + 'project.statuses.created': 'Créé', + 'project.statuses.initializing': 'Initialisation en cours', + 'project.statuses.initialized': 'Initialisé', + 'project.statuses.inProgress': 'En cours', + 'project.statuses.completed': 'Terminé', + 'project.statuses.failed': 'Échoué', 'common.newProject': 'Nouveau projet', 'wizard.cancel': 'Annuler', 'wizard.back': 'Retour', @@ -52,6 +59,12 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.phases.analyze': 'Analyser', 'module.phases.migrate': 'Migrer', 'module.phases.publish': 'Publier', + 'module.summary.total': 'Total', + 'module.summary.finished': 'Terminé', + 'module.summary.waiting': 'En attente', + 'module.summary.pending': 'En attente', + 'module.summary.running': 'En cours', + 'module.summary.error': 'Erreur', 'module.actions.runNextPhase': 'Exécuter la phase suivante', 'module.lastPhase': 'Dernière phase', 'module.name': 'Nom', @@ -62,6 +75,11 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.finishedAt': 'Terminé le', 'artifact.types.migration_plan': 'Plan de migration du projet', 'artifact.types.module_migration_plan': 'Plan du module', + 'module.statuses.none': '-', + 'module.statuses.pending': 'En attente', + 'module.statuses.running': 'En cours', + 'module.statuses.success': 'Succès', + 'module.statuses.error': 'Erreur', 'artifact.types.migrated_sources': 'Sources migrées', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index 8b5ce2e5f86..3f567a1b468 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -43,6 +43,13 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'project.id': 'ID', 'project.abbreviation': 'Abbreviazione', 'project.createdBy': 'Creato da', + 'project.statuses.none': '-', + 'project.statuses.created': 'Creato', + 'project.statuses.initializing': 'In inizializzazione', + 'project.statuses.initialized': 'Inizializzato', + 'project.statuses.inProgress': 'In corso', + 'project.statuses.completed': 'Completato', + 'project.statuses.failed': 'Fallito', 'common.newProject': 'Nuovo progetto', 'wizard.cancel': 'Annulla', 'wizard.back': 'Indietro', @@ -52,6 +59,12 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.phases.analyze': 'Analizzare', 'module.phases.migrate': 'Migrare', 'module.phases.publish': 'Pubblicare', + 'module.summary.total': 'Totale', + 'module.summary.finished': 'Completato', + 'module.summary.waiting': 'In attesa', + 'module.summary.pending': 'In attesa', + 'module.summary.running': 'In esecuzione', + 'module.summary.error': 'Errore', 'module.actions.runNextPhase': 'Esegui fase successiva', 'module.lastPhase': 'Ultima fase', 'module.name': 'Nome', @@ -62,6 +75,11 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.finishedAt': 'Terminato il', 'artifact.types.migration_plan': 'Piano di migrazione', 'artifact.types.module_migration_plan': 'Piano del modulo', + 'module.statuses.none': '-', + 'module.statuses.pending': 'In attesa', + 'module.statuses.running': 'In esecuzione', + 'module.statuses.success': 'Successo', + 'module.statuses.error': 'Errore', 'artifact.types.migrated_sources': 'Sorgenti migrate', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/ref.ts b/workspaces/x2a/plugins/x2a/src/translations/ref.ts index 98cbfff1098..423398df24b 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/ref.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/ref.ts @@ -54,6 +54,15 @@ export const x2aPluginMessages = { id: 'ID', abbreviation: 'Abbreviation', createdBy: 'Created By', + statuses: { + none: '-', + created: 'Created', + initializing: 'Initializing', + initialized: 'Initialized', + inProgress: 'In progress', + completed: 'Completed', + failed: 'Failed', + }, }, common: { newProject: 'New Project', @@ -71,6 +80,14 @@ export const x2aPluginMessages = { migrate: 'Migrate', publish: 'Publish', }, + summary: { + total: 'Total', + finished: 'Finished', + waiting: 'Waiting', + pending: 'Pending', + running: 'Running', + error: 'Error', + }, actions: { runNextPhase: 'Run Next Phase', }, @@ -81,6 +98,13 @@ export const x2aPluginMessages = { artifacts: 'Artifacts', startedAt: 'Started At', finishedAt: 'Finished At', + statuses: { + none: '-', + pending: 'Pending', + running: 'Running', + success: 'Success', + error: 'Error', + }, }, artifact: { types: { diff --git a/workspaces/x2a/tsconfig.json b/workspaces/x2a/tsconfig.json index eac19c5b926..e0ba8569c08 100644 --- a/workspaces/x2a/tsconfig.json +++ b/workspaces/x2a/tsconfig.json @@ -8,7 +8,7 @@ "plugins/*/dev", "plugins/*/migrations" ], - "exclude": ["node_modules"], + "exclude": ["node_modules", "**/node_modules"], "compilerOptions": { "outDir": "dist-types", "rootDir": ".", From 649acaa6a7a2a8a9d4bc4d4c4386bc1d033f3146 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 11 Feb 2026 11:44:58 +0100 Subject: [PATCH 2/4] Add errorDetails to the UI --- .../x2a-backend/src/schema/openapi.yaml | 3 + .../openapi/generated/models/Module.model.ts | 4 + .../src/schema/openapi/generated/router.ts | 4 + .../src/services/X2ADatabaseService/index.ts | 2 +- .../X2ADatabaseService/modules.test.ts | 41 +++++-- .../X2ADatabaseService/status.test.ts | 103 +++++++++++++++--- .../src/services/X2ADatabaseService/status.ts | 27 +++-- .../openapi/generated/models/Module.model.ts | 4 + .../x2a/plugins/x2a-common/report.api.md | 1 + workspaces/x2a/plugins/x2a/report.api.md | 21 +--- .../ModuleTable/ModuleStatusCell.tsx | 42 +++++++ .../components/ModuleTable/ModuleTable.tsx | 30 ++--- 12 files changed, 208 insertions(+), 74 deletions(-) create mode 100644 workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleStatusCell.tsx diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 98689bdcf39..0ae1f543d0d 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -505,6 +505,9 @@ components: $ref: '#/components/schemas/Job' status: $ref: '#/components/schemas/ModuleStatus' + errorDetails: + type: string + description: Detailed error information if the module failed to execute required: - id - name diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts index 1a723545b71..89e416caf30 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts @@ -44,4 +44,8 @@ export interface Module { migrate?: Job; publish?: Job; status?: ModuleStatus; + /** + * Detailed error information if the module failed to execute + */ + errorDetails?: string; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index e09728373f7..df44bd2df4c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -748,6 +748,10 @@ export const spec = { }, "status": { "$ref": "#/components/schemas/ModuleStatus" + }, + "errorDetails": { + "type": "string", + "description": "Detailed error information if the module failed to execute" } }, "required": [ diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index 3001f974660..a1598755e11 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -215,7 +215,7 @@ export class X2ADatabaseService { return { ...module, ...lastJobs, - status: calculateModuleStatus(lastJobs), + ...calculateModuleStatus(lastJobs), }; }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts index 10778a9dda1..086e4b5cf41 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/modules.test.ts @@ -401,15 +401,42 @@ describe('X2ADatabaseService – modules', () => { status: 'success', }); + const modError = await service.createModule({ + name: 'Module With Error', + sourcePath: '/with-error', + projectId: project.id, + }); + const errorJob = await service.createJob({ + projectId: project.id, + moduleId: modError.id, + phase: 'analyze', + status: 'pending', + callbackToken: 'tk2', + }); + await service.updateJob({ + id: errorJob.id, + status: 'error', + errorDetails: 'Analyze failed: timeout', + }); + const modules = await service.listModules({ projectId: project.id }); - expect(modules).toHaveLength(1); - expect(modules[0].analyze).toBeDefined(); - expect(modules[0].analyze?.id).toBe(analyzeJob.id); - expect(modules[0].analyze?.phase).toBe('analyze'); - expect(modules[0].analyze).not.toHaveProperty('callbackToken'); - expect(modules[0].migrate).toBeUndefined(); - expect(modules[0].publish).toBeUndefined(); + expect(modules).toHaveLength(2); + const successModule = modules.find(m => m.name === 'Module With Jobs'); + expect(successModule).toBeDefined(); + expect(successModule?.analyze).toBeDefined(); + expect(successModule?.analyze?.id).toBe(analyzeJob.id); + expect(successModule?.analyze?.phase).toBe('analyze'); + expect(successModule?.analyze).not.toHaveProperty('callbackToken'); + expect(successModule?.migrate).toBeUndefined(); + expect(successModule?.publish).toBeUndefined(); + expect(successModule?.status).toBe('success'); + expect(successModule?.errorDetails ?? undefined).toBeUndefined(); + + const errorModule = modules.find(m => m.name === 'Module With Error'); + expect(errorModule).toBeDefined(); + expect(errorModule?.status).toBe('error'); + expect(errorModule?.errorDetails).toBe('Analyze failed: timeout'); }, LONG_TEST_TIMEOUT, ); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts index c5864f31c52..3c3c0605b23 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts @@ -22,7 +22,7 @@ import type { import { calculateModuleStatus, calculateProjectStatus } from './status'; -function job(status: Job['status']): Job { +function job(status: Job['status'], options?: { errorDetails?: string }): Job { return { id: 'job-id', projectId: 'project-id', @@ -30,29 +30,51 @@ function job(status: Job['status']): Job { phase: 'analyze', k8sJobName: 'k8s-job', status, + ...(options?.errorDetails !== undefined && { + errorDetails: options.errorDetails, + }), }; } describe('calculateModuleStatus', () => { it('returns pending when no jobs are provided', () => { - expect(calculateModuleStatus({})).toBe('pending'); + expect(calculateModuleStatus({})).toEqual({ + status: 'pending', + errorDetails: undefined, + }); }); it('returns analyze status when only analyze job is provided', () => { - expect(calculateModuleStatus({ analyze: job('pending') })).toBe('pending'); - expect(calculateModuleStatus({ analyze: job('running') })).toBe('running'); - expect(calculateModuleStatus({ analyze: job('success') })).toBe('success'); - expect(calculateModuleStatus({ analyze: job('error') })).toBe('error'); + expect(calculateModuleStatus({ analyze: job('pending') }).status).toBe( + 'pending', + ); + expect(calculateModuleStatus({ analyze: job('running') }).status).toBe( + 'running', + ); + expect(calculateModuleStatus({ analyze: job('success') }).status).toBe( + 'success', + ); + expect(calculateModuleStatus({ analyze: job('error') }).status).toBe( + 'error', + ); }); it('returns migrate status when only migrate job is provided', () => { - expect(calculateModuleStatus({ migrate: job('success') })).toBe('success'); - expect(calculateModuleStatus({ migrate: job('error') })).toBe('error'); + expect(calculateModuleStatus({ migrate: job('success') }).status).toBe( + 'success', + ); + expect(calculateModuleStatus({ migrate: job('error') }).status).toBe( + 'error', + ); }); it('returns publish status when only publish job is provided', () => { - expect(calculateModuleStatus({ publish: job('success') })).toBe('success'); - expect(calculateModuleStatus({ publish: job('running') })).toBe('running'); + expect(calculateModuleStatus({ publish: job('success') }).status).toBe( + 'success', + ); + expect(calculateModuleStatus({ publish: job('running') }).status).toBe( + 'running', + ); }); it('prefers migrate over analyze when both are provided', () => { @@ -60,13 +82,13 @@ describe('calculateModuleStatus', () => { calculateModuleStatus({ analyze: job('success'), migrate: job('error'), - }), + }).status, ).toBe('error'); expect( calculateModuleStatus({ analyze: job('error'), migrate: job('success'), - }), + }).status, ).toBe('success'); }); @@ -76,14 +98,14 @@ describe('calculateModuleStatus', () => { analyze: job('success'), migrate: job('success'), publish: job('error'), - }), + }).status, ).toBe('error'); expect( calculateModuleStatus({ analyze: job('error'), migrate: job('error'), publish: job('success'), - }), + }).status, ).toBe('success'); }); @@ -95,9 +117,60 @@ describe('calculateModuleStatus', () => { analyze: job('error'), migrate: job('success'), publish: job('success'), - }), + }).status, ).toBe('success'); }); + + describe('errorDetails', () => { + it('returns errorDetails from analyze job when only analyze is provided', () => { + const result = calculateModuleStatus({ + analyze: job('error', { errorDetails: 'Analyze failed: timeout' }), + }); + expect(result.status).toBe('error'); + expect(result.errorDetails).toBe('Analyze failed: timeout'); + }); + + it('returns errorDetails from migrate job when migrate is the last phase', () => { + const result = calculateModuleStatus({ + analyze: job('success'), + migrate: job('error', { errorDetails: 'Migration failed' }), + }); + expect(result.status).toBe('error'); + expect(result.errorDetails).toBe('Migration failed'); + }); + + it('returns errorDetails from publish job when publish is the last phase', () => { + const result = calculateModuleStatus({ + analyze: job('success'), + migrate: job('success'), + publish: job('error', { errorDetails: 'Publish failed' }), + }); + expect(result.status).toBe('error'); + expect(result.errorDetails).toBe('Publish failed'); + }); + + it('returns undefined errorDetails when the chosen phase job has none', () => { + expect( + calculateModuleStatus({ analyze: job('error') }).errorDetails, + ).toBeUndefined(); + expect( + calculateModuleStatus({ publish: job('success') }).errorDetails, + ).toBeUndefined(); + }); + + it('prefers errorDetails from last phase when multiple phases have errorDetails', () => { + const result = calculateModuleStatus({ + analyze: job('error', { errorDetails: 'Analyze error' }), + migrate: job('error', { errorDetails: 'Migrate error' }), + publish: job('error', { errorDetails: 'Publish error' }), + }); + expect(result.errorDetails).toBe('Publish error'); + }); + + it('returns undefined errorDetails when no jobs are provided', () => { + expect(calculateModuleStatus({}).errorDetails).toBeUndefined(); + }); + }); }); /** Minimal module for project status tests; only status and publish are used by calculateProjectStatus. */ diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts index ef6df516f77..1956716cebe 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts @@ -36,18 +36,18 @@ export function calculateModuleStatus({ analyze?: Job; migrate?: Job; publish?: Job; -}): ModuleStatus { +}): { status: ModuleStatus; errorDetails?: string } { if (publish) { - return publish.status; + return { status: publish.status, errorDetails: publish.errorDetails }; } if (migrate) { - return migrate.status; + return { status: migrate.status, errorDetails: migrate.errorDetails }; } if (analyze) { - return analyze.status; + return { status: analyze.status, errorDetails: analyze.errorDetails }; } - return 'pending'; + return { status: 'pending', errorDetails: undefined }; } /** @@ -96,17 +96,16 @@ export function calculateProjectStatus( state = 'failed'; } else if (['pending', 'running'].includes(initJob?.status ?? '')) { state = 'initializing'; - } else { - if (initJob?.status === 'success') { - state = 'initialized'; - if (finished === total) { - state = 'completed'; - } else { - state = 'inProgress'; - } + } else if (initJob?.status === 'success') { + if (finished === total) { + state = 'completed'; + } else if (pending || waiting || running) { + state = 'inProgress'; } else { - state = 'failed'; + state = 'initialized'; } + } else { + state = 'failed'; } return { diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts index 1a723545b71..89e416caf30 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts @@ -44,4 +44,8 @@ export interface Module { migrate?: Job; publish?: Job; status?: ModuleStatus; + /** + * Detailed error information if the module failed to execute + */ + errorDetails?: string; } diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 9f21a112077..2013e9038bd 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -107,6 +107,7 @@ export type MigrationPhase = 'init' | 'analyze' | 'migrate' | 'publish'; export interface Module { // (undocumented) analyze?: Job; + errorDetails?: string; id: string; // (undocumented) migrate?: Job; diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index 0ad05a977f5..95d0f846c94 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -26,25 +26,6 @@ root: RouteRef; // @public export const x2aPluginTranslationRef: TranslationRef<"plugin.x2a", { -<<<<<<< HEAD -readonly "module.name": string; -readonly "module.status": string; -readonly "module.actions.runNextPhase": string; -readonly "module.artifacts": string; -readonly "module.phases.none": string; -readonly "module.phases.analyze": string; -readonly "module.phases.init": string; -readonly "module.phases.migrate": string; -readonly "module.phases.publish": string; -readonly "module.lastPhase": string; -readonly "module.sourcePath": string; -readonly "module.startedAt": string; -readonly "module.finishedAt": string; -readonly "page.title": string; -readonly "page.subtitle": string; -readonly "page.devTitle": string; -======= ->>>>>>> 33376cb33b (feat(x2a): add project and module status) readonly "table.columns.name": string; readonly "table.columns.description": string; readonly "table.columns.abbreviation": string; @@ -96,6 +77,8 @@ readonly "module.phases.publish": string; readonly "module.lastPhase": string; readonly "module.sourcePath": string; readonly "module.artifacts": string; +readonly "module.startedAt": string; +readonly "module.finishedAt": string; readonly "artifact.types.migration_plan": string; readonly "artifact.types.module_migration_plan": string; readonly "artifact.types.migrated_sources": string; diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleStatusCell.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleStatusCell.tsx new file mode 100644 index 00000000000..62db8ae12d9 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleStatusCell.tsx @@ -0,0 +1,42 @@ +/** + * 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 { ModuleStatus } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { Tooltip } from '@material-ui/core'; + +import { useTranslation } from '../../hooks/useTranslation'; + +export const ModuleStatusCell = ({ + status, + errorDetails, +}: { + status?: ModuleStatus; + errorDetails?: string; +}) => { + const { t } = useTranslation(); + + const statusText = t(`module.statuses.${status || 'none'}`); + if (errorDetails) { + return ( +
+ +
{statusText}
+
+
+ ); + } + return
{statusText}
; +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx index cd4b65cff11..797e68c3951 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx @@ -32,6 +32,7 @@ import { useClientService } from '../../ClientService'; import { Artifacts } from './Artifacts'; import { humanizeDate } from '../tools'; import { getAuthTokenDescriptor, useRepoAuthentication } from '../../repoAuth'; +import { ModuleStatusCell } from './ModuleStatusCell'; const getLastJob = (rowData: Module) => { const phases: ('publish' | 'migrate' | 'analyze')[] = [ @@ -87,13 +88,6 @@ const useColumns = ({ [targetRepoUrl], ); - const statusCell = useCallback( - (rowData: Module) => { - return
{t(`module.statuses.${rowData.status || 'none'}`)}
; - }, - [t], - ); - const startedAtCell = useCallback( (rowData: Module) => { const lastJob = getLastJob(rowData); @@ -116,26 +110,26 @@ const useColumns = ({ }, [t], ); - return useMemo((): TableColumn[] => { return [ { field: 'name', title: t('module.name') }, - // TODO: errorDetail - { field: 'status', render: statusCell, title: t('module.status') }, + { + field: 'status', + render: (rowData: Module) => ( + + ), + title: t('module.status'), + }, { field: 'sourcePath', title: t('module.sourcePath') }, { render: lastPhaseCell, title: t('module.lastPhase') }, { render: artifactsCell, title: t('module.artifacts') }, { render: startedAtCell, title: t('module.startedAt') }, { render: finishedAtCell, title: t('module.finishedAt') }, ]; - }, [ - t, - lastPhaseCell, - artifactsCell, - statusCell, - startedAtCell, - finishedAtCell, - ]); + }, [t, lastPhaseCell, artifactsCell, startedAtCell, finishedAtCell]); }; const canRunNextPhase = ({ module }: { module: Module }) => { From d538796bfafd6619a5aff04bf668573537b4ed89 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 11 Feb 2026 13:47:32 +0100 Subject: [PATCH 3/4] yarn.lock --- workspaces/x2a/yarn.lock | 239 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 5 deletions(-) diff --git a/workspaces/x2a/yarn.lock b/workspaces/x2a/yarn.lock index ef3e73c244d..97932e4b2d1 100644 --- a/workspaces/x2a/yarn.lock +++ b/workspaces/x2a/yarn.lock @@ -8173,7 +8173,7 @@ __metadata: languageName: node linkType: hard -"@mui/utils@npm:^7.3.7": +"@mui/utils@npm:^7.3.5, @mui/utils@npm:^7.3.7": version: 7.3.7 resolution: "@mui/utils@npm:7.3.7" dependencies: @@ -8193,6 +8193,90 @@ __metadata: languageName: node linkType: hard +"@mui/x-charts-vendor@npm:8.26.0": + version: 8.26.0 + resolution: "@mui/x-charts-vendor@npm:8.26.0" + dependencies: + "@babel/runtime": ^7.28.4 + "@types/d3-array": ^3.2.2 + "@types/d3-color": ^3.1.3 + "@types/d3-format": ^3.0.4 + "@types/d3-interpolate": ^3.0.4 + "@types/d3-path": ^3.1.1 + "@types/d3-scale": ^4.0.9 + "@types/d3-shape": ^3.1.7 + "@types/d3-time": ^3.0.4 + "@types/d3-time-format": ^4.0.3 + "@types/d3-timer": ^3.0.2 + d3-array: ^3.2.4 + d3-color: ^3.1.0 + d3-format: ^3.1.0 + d3-interpolate: ^3.0.1 + d3-path: ^3.1.0 + d3-scale: ^4.0.2 + d3-shape: ^3.2.0 + d3-time: ^3.1.0 + d3-time-format: ^4.1.0 + d3-timer: ^3.0.1 + flatqueue: ^3.0.0 + internmap: ^2.0.3 + checksum: 0ba4e56b18a84eab339f371c1a63a288c6879b5c4ed7d2c5be1f33b074e393953bab3462a4850e373d136ce08d1ae14e7cd921a9258dd017a0e000868718c8cb + languageName: node + linkType: hard + +"@mui/x-charts@npm:^8.27.0": + version: 8.27.0 + resolution: "@mui/x-charts@npm:8.27.0" + dependencies: + "@babel/runtime": ^7.28.4 + "@mui/utils": ^7.3.5 + "@mui/x-charts-vendor": 8.26.0 + "@mui/x-internal-gestures": 0.4.0 + "@mui/x-internals": 8.26.0 + bezier-easing: ^2.1.0 + clsx: ^2.1.1 + prop-types: ^15.8.1 + reselect: ^5.1.1 + use-sync-external-store: ^1.6.0 + peerDependencies: + "@emotion/react": ^11.9.0 + "@emotion/styled": ^11.8.1 + "@mui/material": ^5.15.14 || ^6.0.0 || ^7.0.0 + "@mui/system": ^5.15.14 || ^6.0.0 || ^7.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + checksum: cd8c656683509053f006eee8a66f0763d59a093324dbe662b2fd86b685514741ac847cff865e38e38cd3627bde1f2ca77ec64da00496df38f64572ec3628e75a + languageName: node + linkType: hard + +"@mui/x-internal-gestures@npm:0.4.0": + version: 0.4.0 + resolution: "@mui/x-internal-gestures@npm:0.4.0" + dependencies: + "@babel/runtime": ^7.28.4 + checksum: d8c46e4d022d47a9c67779e8146b1471e2746a66393aa14411a19f90d4daabe69f3461a2d9f8355fadee149647a4af2f6a53566ebc277591eba4f46e8b72cc43 + languageName: node + linkType: hard + +"@mui/x-internals@npm:8.26.0": + version: 8.26.0 + resolution: "@mui/x-internals@npm:8.26.0" + dependencies: + "@babel/runtime": ^7.28.4 + "@mui/utils": ^7.3.5 + reselect: ^5.1.1 + use-sync-external-store: ^1.6.0 + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 153b84993c6365f51748be041c5fda27942bae9bec2e9324c64055d1df1a46c387dc703544e1b006a4d59c34aa9b9227b65a07ef37e4e0ae9dd73b3e7af5b4d1 + languageName: node + linkType: hard + "@n1ru4l/push-pull-async-iterable-iterator@npm:^3.1.0": version: 3.2.0 resolution: "@n1ru4l/push-pull-async-iterable-iterator@npm:3.2.0" @@ -11631,6 +11715,7 @@ __metadata: "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 "@mui/material": ^7.3.7 + "@mui/x-charts": ^8.27.0 "@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*" "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -14466,6 +14551,82 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:^3.2.2": + version: 3.2.2 + resolution: "@types/d3-array@npm:3.2.2" + checksum: 72e8e2abe0911cb431d6f3fe0a1f71b915356b679d4d9c826f52941bb30210c0fe8299dde066b08d9986754c620f031b13b13ab6dfc60d404eceab66a075dd5d + languageName: node + linkType: hard + +"@types/d3-color@npm:*, @types/d3-color@npm:^3.1.3": + version: 3.1.3 + resolution: "@types/d3-color@npm:3.1.3" + checksum: 8a0e79a709929502ec4effcee2c786465b9aec51b653ba0b5d05dbfec3e84f418270dd603002d94021885061ff592f614979193bd7a02ad76317f5608560e357 + languageName: node + linkType: hard + +"@types/d3-format@npm:^3.0.4": + version: 3.0.4 + resolution: "@types/d3-format@npm:3.0.4" + checksum: e69421cd93861a0c080084b0b23d4a5d6a427497559e46898189002fb756dae2c7c858b465308f6bcede7272b90e39ce8adab810bded2309035a5d9556c59134 + languageName: node + linkType: hard + +"@types/d3-interpolate@npm:^3.0.4": + version: 3.0.4 + resolution: "@types/d3-interpolate@npm:3.0.4" + dependencies: + "@types/d3-color": "*" + checksum: efd2770e174e84fc7316fdafe03cf3688451f767dde1fa6211610137f495be7f3923db7e1723a6961a0e0e9ae0ed969f4f47c038189fa0beb1d556b447922622 + languageName: node + linkType: hard + +"@types/d3-path@npm:*, @types/d3-path@npm:^3.1.1": + version: 3.1.1 + resolution: "@types/d3-path@npm:3.1.1" + checksum: fee8f6b0d3b28a3611c7d7fda3bf2f79392ded266f54b03a220f205c42117644bdcd33dcbf4853da3cca02229f1c669d2a60d5d297a24ce459ba8271ccb26c03 + languageName: node + linkType: hard + +"@types/d3-scale@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/d3-scale@npm:4.0.9" + dependencies: + "@types/d3-time": "*" + checksum: c44265a38e538983686b1b8d159abfb4e81c09b33316f3a68f0f372d38400fa950ad531644d25230cc7b48ea5adb50270fc54823f088979ade62dcd0225f7aa3 + languageName: node + linkType: hard + +"@types/d3-shape@npm:^3.1.7": + version: 3.1.8 + resolution: "@types/d3-shape@npm:3.1.8" + dependencies: + "@types/d3-path": "*" + checksum: 659d51882dccc85d24817bdbcd50589212d12e24eb2aad19bae073665ed25443026e120966faa8523f0412f8a30f7c16002499cea3eb87d25b3011e0ee42e6a2 + languageName: node + linkType: hard + +"@types/d3-time-format@npm:^4.0.3": + version: 4.0.3 + resolution: "@types/d3-time-format@npm:4.0.3" + checksum: e981fc9780697a9d8c5d1ddf1167d9c6bc28e4e610afddff1384fe55e6eb52cb65309b2a0a1d4cf817413b0a80b9f1a652fe0b2cb8054ace4eafff80a6093aa5 + languageName: node + linkType: hard + +"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.4": + version: 3.0.4 + resolution: "@types/d3-time@npm:3.0.4" + checksum: 0c296884571ce70c4bbd4ea9cd1c93c0c8aee602c6c806b056187dd4ee49daf70c2f41da94b25ba0d796edf8ca83cbb87fe6d1cdda7ca669ab800170ece1c12b + languageName: node + linkType: hard + +"@types/d3-timer@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/d3-timer@npm:3.0.2" + checksum: 1643eebfa5f4ae3eb00b556bbc509444d88078208ec2589ddd8e4a24f230dd4cf2301e9365947e70b1bee33f63aaefab84cd907822aae812b9bc4871b98ab0e1 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.7": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -16947,6 +17108,13 @@ __metadata: languageName: node linkType: hard +"bezier-easing@npm:^2.1.0": + version: 2.1.0 + resolution: "bezier-easing@npm:2.1.0" + checksum: ec22666b5385979b3e7a63eaf2631395b1be5beb3aed4f14cc70ae9eb09032082584f7e3eec045e4c122466987ed7e159f5b39f82b8a9870cd55e75b0d55a7d5 + languageName: node + linkType: hard + "bfj@npm:^8.0.0": version: 8.0.0 resolution: "bfj@npm:8.0.0" @@ -18963,7 +19131,16 @@ __metadata: languageName: node linkType: hard -"d3-color@npm:1 - 3": +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.2.4": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: 1 - 2 + checksum: a5976a6d6205f69208478bb44920dd7ce3e788c9dceb86b304dbe401a4bfb42ecc8b04c20facde486e9adcb488b5d1800d49393a3f81a23902b68158e12cddd0 + languageName: node + linkType: hard + +"d3-color@npm:1 - 3, d3-color@npm:^3.1.0": version: 3.1.0 resolution: "d3-color@npm:3.1.0" checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b @@ -18994,7 +19171,14 @@ __metadata: languageName: node linkType: hard -"d3-interpolate@npm:1 - 3": +"d3-format@npm:1 - 3, d3-format@npm:^3.1.0": + version: 3.1.2 + resolution: "d3-format@npm:3.1.2" + checksum: 2ce13417b3186311df3fd924028cd516ec3e96d7c3eb6df9c83f6c2ed43de1717e6c5119a385b7744ef84e2b8a4c678ad95a2b2998391803ceb0d809e235cff4 + languageName: node + linkType: hard + +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -19010,6 +19194,19 @@ __metadata: languageName: node linkType: hard +"d3-scale@npm:^4.0.2": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: 2.10.0 - 3 + d3-format: 1 - 3 + d3-interpolate: 1.2.0 - 3 + d3-time: 2.1.1 - 3 + d3-time-format: 2 - 4 + checksum: a9c770d283162c3bd11477c3d9d485d07f8db2071665f1a4ad23eec3e515e2cefbd369059ec677c9ac849877d1a765494e90e92051d4f21111aa56791c98729e + languageName: node + linkType: hard + "d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": version: 3.0.0 resolution: "d3-selection@npm:3.0.0" @@ -19017,7 +19214,7 @@ __metadata: languageName: node linkType: hard -"d3-shape@npm:^3.0.0": +"d3-shape@npm:^3.0.0, d3-shape@npm:^3.2.0": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" dependencies: @@ -19026,7 +19223,25 @@ __metadata: languageName: node linkType: hard -"d3-timer@npm:1 - 3": +"d3-time-format@npm:2 - 4, d3-time-format@npm:^4.1.0": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: 1 - 3 + checksum: 7342bce28355378152bbd4db4e275405439cabba082d9cd01946d40581140481c8328456d91740b0fe513c51ec4a467f4471ffa390c7e0e30ea30e9ec98fcdf4 + languageName: node + linkType: hard + +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: 2 - 3 + checksum: 613b435352a78d9f31b7f68540788186d8c331b63feca60ad21c88e9db1989fe888f97f242322ebd6365e45ec3fb206a4324cd4ca0dfffa1d9b5feb856ba00a7 + languageName: node + linkType: hard + +"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1": version: 3.0.1 resolution: "d3-timer@npm:3.0.1" checksum: 1cfddf86d7bca22f73f2c427f52dfa35c49f50d64e187eb788dcad6e927625c636aa18ae4edd44d084eb9d1f81d8ca4ec305dae7f733c15846a824575b789d73 @@ -21390,6 +21605,13 @@ __metadata: languageName: node linkType: hard +"flatqueue@npm:^3.0.0": + version: 3.0.0 + resolution: "flatqueue@npm:3.0.0" + checksum: fb1129fa8acc2255e42430345e46d4a5430ed45772f4e1c3ebf9fd2c7f16d9dae8a93cf0d651fd6c22a5e437d31c6971ff633bba41b60426cb7fdfdbddaa06bf + languageName: node + linkType: hard + "flatstr@npm:^1.0.12": version: 1.0.12 resolution: "flatstr@npm:1.0.12" @@ -23357,6 +23579,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2, internmap@npm:^2.0.3": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241 + languageName: node + linkType: hard + "interpret@npm:^2.2.0": version: 2.2.0 resolution: "interpret@npm:2.2.0" From 9f043f79d6f23c596379cde0a2f41eee5e69f44e Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Thu, 12 Feb 2026 11:40:32 +0100 Subject: [PATCH 4/4] review --- .../x2a-backend/src/schema/openapi.yaml | 7 +- .../src/schema/openapi/generated/router.ts | 3 +- .../src/services/X2ADatabaseService/index.ts | 23 +-- .../X2ADatabaseService/status.test.ts | 174 ++++++++++++++++-- .../src/services/X2ADatabaseService/status.ts | 16 +- 5 files changed, 176 insertions(+), 47 deletions(-) diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 0ae1f543d0d..845dabb9c52 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -542,9 +542,9 @@ components: Project status state. It is calculated from the status of its modules. - created: Project is created but not yet initialized - - initializing: Project's init job is running - - initialized: Project's init job finished successfully, pending next phase execution - - inProgress: A non-init phase is running + - initializing: Project's init job is running or scheduling + - initialized: Project's init job finished successfully. Either module list is empty or all modules are in pending state. + - inProgress: At least one module is beyond the pending state. - completed: All modules are in success state - failed: At least one module is in error state enum: @@ -580,7 +580,6 @@ components: - total - finished - waiting - - completed - pending - running - error diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index df44bd2df4c..1719935d669 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -782,7 +782,7 @@ export const spec = { }, "ProjectStatusState": { "type": "string", - "description": "Project status state.\nIt is calculated from the status of its modules.\n- created: Project is created but not yet initialized\n- initializing: Project's init job is running\n- initialized: Project's init job finished successfully, pending next phase execution\n- inProgress: A non-init phase is running\n- completed: All modules are in success state\n- failed: At least one module is in error state\n", + "description": "Project status state.\nIt is calculated from the status of its modules.\n- created: Project is created but not yet initialized\n- initializing: Project's init job is running or scheduling\n- initialized: Project's init job finished successfully. Either module list is empty or all modules are in pending state.\n- inProgress: At least one module is beyond the pending state.\n- completed: All modules are in success state\n- failed: At least one module is in error state\n", "enum": [ "created", "initializing", @@ -824,7 +824,6 @@ export const spec = { "total", "finished", "waiting", - "completed", "pending", "running", "error" diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index a1598755e11..9eaf605444d 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -59,35 +59,26 @@ export class X2ADatabaseService { this.#jobOps = new JobOperations(logger, dbClient); } - private async getMigrationPlanForProject( - projectId: string, - ): Promise { - const lastInitJob = await this.#jobOps.listJobs({ - projectId, - phase: 'init', - lastJobOnly: true, - }); - if (lastInitJob.length === 0) return undefined; - return lastInitJob[0].artifacts?.find( - artifact => artifact.type === 'migration_plan', - ); - } - /** * Enriches a project with migration plan and status (used by listProjects and getProject). */ private async enrichProject(project: Project): Promise { const projectId = project.id; - project.migrationPlan = await this.getMigrationPlanForProject(projectId); const initJob = await this.listJobs({ projectId, phase: 'init', lastJobOnly: true, }); + const lastInitJob = initJob[0]; + project.status = calculateProjectStatus( await this.listModules({ projectId }), - initJob[0], + lastInitJob, + ); + + project.migrationPlan = lastInitJob?.artifacts?.find( + artifact => artifact.type === 'migration_plan', ); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts index 3c3c0605b23..64b4a2d8cb1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.test.ts @@ -195,9 +195,18 @@ function initJob(status: Job['status']): Job { return job(status); } +/** + * Project state tests follow OpenAPI ProjectStatusState (openapi.yaml 543–549): + * - created: Project is created but not yet initialized + * - initializing: Project's init job is running or scheduling + * - initialized: Project's init job finished successfully. Either module list is empty or all modules are in pending state. + * - inProgress: At least one module is beyond the pending state. + * - completed: All modules are in success state + * - failed: At least one module is in error state (or init failed / no init with modules) + */ describe('calculateProjectStatus', () => { describe('state: created', () => { - it('returns created when no init job and no modules', () => { + it('returns created when no init job and no modules (project created but not yet initialized)', () => { const result = calculateProjectStatus([], undefined); expect(result.state).toBe('created'); expect(result.modulesSummary).toEqual({ @@ -209,10 +218,19 @@ describe('calculateProjectStatus', () => { error: 0, }); }); + + it('does not return created when init job exists even with no modules', () => { + expect(calculateProjectStatus([], initJob('pending')).state).toBe( + 'initializing', + ); + expect(calculateProjectStatus([], initJob('success')).state).toBe( + 'initialized', + ); + }); }); describe('state: failed', () => { - it('returns failed when at least one module has status error', () => { + it('returns failed when at least one module is in error state', () => { const result = calculateProjectStatus([ module('success', { publishStatus: 'success' }), module('error'), @@ -221,7 +239,20 @@ describe('calculateProjectStatus', () => { expect(result.modulesSummary.error).toBe(1); }); - it('returns failed when no init job is provided (init never ran)', () => { + it('returns failed when only one module and it is in error', () => { + const result = calculateProjectStatus([module('error')]); + expect(result.state).toBe('failed'); + expect(result.modulesSummary.error).toBe(1); + expect(result.modulesSummary.total).toBe(1); + }); + + it('returns failed when all modules are in error', () => { + const result = calculateProjectStatus([module('error'), module('error')]); + expect(result.state).toBe('failed'); + expect(result.modulesSummary.error).toBe(2); + }); + + it('returns failed when no init job is provided but project has modules (init never ran)', () => { const result = calculateProjectStatus([ module('pending'), module('pending'), @@ -237,7 +268,7 @@ describe('calculateProjectStatus', () => { expect(result.state).toBe('failed'); }); - it('returns failed even when init succeeded if any module is in error', () => { + it('returns failed even when init succeeded if any module is in error (failed takes precedence)', () => { const result = calculateProjectStatus( [module('success', { publishStatus: 'success' }), module('error')], initJob('success'), @@ -248,7 +279,7 @@ describe('calculateProjectStatus', () => { }); describe('state: initializing', () => { - it('returns initializing when init job is pending', () => { + it('returns initializing when init job is pending (scheduling)', () => { const result = calculateProjectStatus( [module('pending'), module('pending')], initJob('pending'), @@ -263,10 +294,58 @@ describe('calculateProjectStatus', () => { ); expect(result.state).toBe('initializing'); }); + + it('returns initializing with empty module list when init is pending', () => { + const result = calculateProjectStatus([], initJob('pending')); + expect(result.state).toBe('initializing'); + }); + + it('returns initializing with empty module list when init is running', () => { + const result = calculateProjectStatus([], initJob('running')); + expect(result.state).toBe('initializing'); + }); + }); + + describe('state: initialized', () => { + it('returns initialized when init succeeded and module list is empty', () => { + const result = calculateProjectStatus([], initJob('success')); + expect(result.state).toBe('initialized'); + expect(result.modulesSummary.total).toBe(0); + expect(result.modulesSummary.finished).toBe(0); + }); + + it('returns initialized when init succeeded and all modules are in pending state', () => { + const result = calculateProjectStatus( + [module('pending'), module('pending')], + initJob('success'), + ); + expect(result.state).toBe('initialized'); + expect(result.modulesSummary.pending).toBe(2); + expect(result.modulesSummary.total).toBe(2); + }); + + it('returns initialized when init succeeded and single module is pending', () => { + const result = calculateProjectStatus( + [module('pending')], + initJob('success'), + ); + expect(result.state).toBe('initialized'); + expect(result.modulesSummary.pending).toBe(1); + }); }); describe('state: inProgress', () => { - it('returns inProgress when init succeeded but not all modules have finished publish', () => { + it('returns inProgress when at least one module is running (beyond pending)', () => { + const result = calculateProjectStatus( + [module('pending'), module('running')], + initJob('success'), + ); + expect(result.state).toBe('inProgress'); + expect(result.modulesSummary.running).toBe(1); + expect(result.modulesSummary.pending).toBe(1); + }); + + it('returns inProgress when at least one module has success but not all finished publish', () => { const result = calculateProjectStatus( [ module('success', { publishStatus: 'success' }), @@ -276,28 +355,52 @@ describe('calculateProjectStatus', () => { ); expect(result.state).toBe('inProgress'); expect(result.modulesSummary.finished).toBe(1); + expect(result.modulesSummary.waiting).toBe(1); expect(result.modulesSummary.total).toBe(2); }); - it('returns inProgress when init succeeded and no module has finished publish', () => { + it('returns inProgress when one running and rest pending', () => { const result = calculateProjectStatus( - [module('pending'), module('running')], + [module('running'), module('pending'), module('pending')], initJob('success'), ); expect(result.state).toBe('inProgress'); - expect(result.modulesSummary.finished).toBe(0); + expect(result.modulesSummary.running).toBe(1); + expect(result.modulesSummary.pending).toBe(2); }); - }); - describe('state: completed', () => { - it('returns completed when init succeeded and there are no modules', () => { - const result = calculateProjectStatus([], initJob('success')); - expect(result.state).toBe('completed'); - expect(result.modulesSummary.finished).toBe(0); - expect(result.modulesSummary.total).toBe(0); + it('returns inProgress when mix of success (waiting), running and pending', () => { + const result = calculateProjectStatus( + [ + module('success'), // waiting for publish + module('running'), + module('pending'), + ], + initJob('success'), + ); + expect(result.state).toBe('inProgress'); + expect(result.modulesSummary.waiting).toBe(1); + expect(result.modulesSummary.running).toBe(1); + expect(result.modulesSummary.pending).toBe(1); + }); + + it('returns inProgress when one module finished publish but others have not', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + module('pending'), + module('pending'), + ], + initJob('success'), + ); + expect(result.state).toBe('inProgress'); + expect(result.modulesSummary.finished).toBe(1); + expect(result.modulesSummary.pending).toBe(2); }); + }); - it('returns completed when init succeeded and every module has finished publish', () => { + describe('state: completed', () => { + it('returns completed only when init succeeded and all modules are in success state (finished publish)', () => { const result = calculateProjectStatus( [ module('success', { publishStatus: 'success' }), @@ -319,6 +422,43 @@ describe('calculateProjectStatus', () => { expect(result.modulesSummary.finished).toBe(1); expect(result.modulesSummary.total).toBe(1); }); + + it('does not return completed when module list is empty (that is initialized)', () => { + const result = calculateProjectStatus([], initJob('success')); + expect(result.state).not.toBe('completed'); + expect(result.state).toBe('initialized'); + }); + }); + + describe('state precedence (per spec)', () => { + it('failed overrides initializing when init is running but a module is error', () => { + // In practice init running implies modules might not have run yet; if they do have error, failed wins. + const result = calculateProjectStatus( + [module('error'), module('pending')], + initJob('running'), + ); + expect(result.state).toBe('failed'); + }); + + it('failed overrides completed when any module is in error', () => { + const result = calculateProjectStatus( + [ + module('success', { publishStatus: 'success' }), + module('success', { publishStatus: 'success' }), + module('error'), + ], + initJob('success'), + ); + expect(result.state).toBe('failed'); + }); + + it('initializing overrides initialized when init is pending even if all modules pending', () => { + const result = calculateProjectStatus( + [module('pending'), module('pending')], + initJob('pending'), + ); + expect(result.state).toBe('initializing'); + }); }); describe('modulesSummary counts', () => { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts index 1956716cebe..b93dd107836 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/status.ts @@ -93,23 +93,23 @@ export function calculateProjectStatus( let state: ProjectStatusState; if (error > 0) { - state = 'failed'; + state = 'failed'; // At least one module is in error state } else if (['pending', 'running'].includes(initJob?.status ?? '')) { - state = 'initializing'; + state = 'initializing'; // Project's init job is running or scheduling } else if (initJob?.status === 'success') { - if (finished === total) { - state = 'completed'; - } else if (pending || waiting || running) { - state = 'inProgress'; + if (total > 0 && finished === total) { + state = 'completed'; // All modules are in success state + } else if (total === 0 || pending === total) { + state = 'initialized'; // Module list is empty or all modules are in pending state } else { - state = 'initialized'; + state = 'inProgress'; // At least one module is beyond the pending state } } else { state = 'failed'; } return { - state: state, + state, modulesSummary: { total, finished,