Skip to content

Commit 8b30238

Browse files
committed
feat(x2a): Adding Projects Details Page, fixes in the Modules Details Page
Signed-off-by: Marek Libra <marek.libra@gmail.com>
1 parent f36a43e commit 8b30238

47 files changed

Lines changed: 1570 additions & 500 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch
3+
'@red-hat-developer-hub/backstage-plugin-x2a-common': patch
4+
'@red-hat-developer-hub/backstage-plugin-x2a': patch
5+
---
6+
7+
Adding Project Details Page and fixing issues in the Module Details Page.

workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createProject.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,6 @@ export function createProjectAction(
226226
// Output the results
227227
ctx.output('projectId', project.id);
228228
ctx.output('initJobId', initResponseData.jobId);
229-
// TODO: Build proper URL of project detail page once implemented
230229
ctx.output('nextUrl', `/x2a/projects/${project.id}`);
231230
},
232231
});

workspaces/x2a/plugins/x2a-backend/src/router/jobs.test.ts

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,262 @@ describe('createRouter – jobs (log)', () => {
3838
await tearDownRouters();
3939
});
4040

41+
describe.each(supportedDatabaseIds)(
42+
'GET /projects/:projectId/log (init phase) - %p',
43+
databaseId => {
44+
let client: Knex;
45+
let x2aDatabase: X2ADatabaseService;
46+
let project: { id: string };
47+
48+
beforeEach(async () => {
49+
const dbSetup = await createDatabase(databaseId);
50+
client = dbSetup.client;
51+
x2aDatabase = X2ADatabaseService.create({
52+
logger: mockServices.logger.mock(),
53+
dbClient: client,
54+
});
55+
56+
project = await createTestProject(x2aDatabase);
57+
}, LONG_TEST_TIMEOUT);
58+
59+
it(
60+
'should return logs from database for finished init job with success status',
61+
async () => {
62+
await createTestJob(x2aDatabase, {
63+
projectId: project.id,
64+
moduleId: null,
65+
phase: 'init',
66+
status: 'success',
67+
log: 'Init job logs from database',
68+
});
69+
70+
const app = await createApp(client);
71+
72+
const response = await request(app)
73+
.get(`/projects/${project.id}/log`)
74+
.send();
75+
76+
expect(response.status).toBe(200);
77+
expect(response.type).toBe('text/plain');
78+
expect(response.text).toBe('Init job logs from database');
79+
},
80+
LONG_TEST_TIMEOUT,
81+
);
82+
83+
it(
84+
'should return logs from database for finished init job with error status',
85+
async () => {
86+
await createTestJob(x2aDatabase, {
87+
projectId: project.id,
88+
moduleId: null,
89+
phase: 'init',
90+
status: 'error',
91+
log: 'Init failed: migration plan error',
92+
});
93+
94+
const app = await createApp(client);
95+
96+
const response = await request(app)
97+
.get(`/projects/${project.id}/log`)
98+
.send();
99+
100+
expect(response.status).toBe(200);
101+
expect(response.type).toBe('text/plain');
102+
expect(response.text).toBe('Init failed: migration plan error');
103+
},
104+
LONG_TEST_TIMEOUT,
105+
);
106+
107+
it(
108+
'should return logs from Kubernetes for running init job',
109+
async () => {
110+
await createTestJob(x2aDatabase, {
111+
projectId: project.id,
112+
moduleId: null,
113+
phase: 'init',
114+
status: 'running',
115+
k8sJobName: 'init-k8s-job',
116+
});
117+
118+
const mockGetJobLogs = jest
119+
.fn()
120+
.mockResolvedValue('Init job logs from Kubernetes');
121+
const app = await createApp(client, undefined, undefined, {
122+
getJobLogs: mockGetJobLogs,
123+
});
124+
125+
const response = await request(app)
126+
.get(`/projects/${project.id}/log`)
127+
.send();
128+
129+
expect(response.status).toBe(200);
130+
expect(response.type).toBe('text/plain');
131+
expect(response.text).toBe('Init job logs from Kubernetes');
132+
expect(mockGetJobLogs).toHaveBeenCalledWith('init-k8s-job', false);
133+
},
134+
LONG_TEST_TIMEOUT,
135+
);
136+
137+
it(
138+
'should call getJobLogs with streaming=true when streaming query param is set',
139+
async () => {
140+
await createTestJob(x2aDatabase, {
141+
projectId: project.id,
142+
moduleId: null,
143+
phase: 'init',
144+
status: 'running',
145+
k8sJobName: 'init-k8s-job',
146+
});
147+
148+
const mockGetJobLogs = jest
149+
.fn()
150+
.mockResolvedValue('Streaming init logs');
151+
const app = await createApp(client, undefined, undefined, {
152+
getJobLogs: mockGetJobLogs,
153+
});
154+
155+
const response = await request(app)
156+
.get(`/projects/${project.id}/log?streaming=true`)
157+
.send();
158+
159+
expect(response.status).toBe(200);
160+
expect(mockGetJobLogs).toHaveBeenCalledWith('init-k8s-job', true);
161+
},
162+
LONG_TEST_TIMEOUT,
163+
);
164+
165+
it(
166+
'should return empty logs when init job has no k8sJobName',
167+
async () => {
168+
await createTestJob(x2aDatabase, {
169+
projectId: project.id,
170+
moduleId: null,
171+
phase: 'init',
172+
status: 'pending',
173+
});
174+
175+
const app = await createApp(client);
176+
177+
const response = await request(app)
178+
.get(`/projects/${project.id}/log`)
179+
.send();
180+
181+
expect(response.status).toBe(200);
182+
expect(response.type).toBe('text/plain');
183+
expect(response.text).toBe('');
184+
},
185+
LONG_TEST_TIMEOUT,
186+
);
187+
188+
it(
189+
'should return 404 when no init job found for project',
190+
async () => {
191+
const app = await createApp(client);
192+
193+
const response = await request(app)
194+
.get(`/projects/${project.id}/log`)
195+
.send();
196+
197+
expect(response.status).toBe(404);
198+
expect(response.body).toMatchObject({
199+
error: {
200+
name: 'NotFoundError',
201+
message: expect.stringContaining('No init job found'),
202+
},
203+
});
204+
},
205+
LONG_TEST_TIMEOUT,
206+
);
207+
208+
it(
209+
'should return 404 when project does not exist',
210+
async () => {
211+
const app = await createApp(client);
212+
213+
const response = await request(app)
214+
.get(`/projects/${nonExistentId}/log`)
215+
.send();
216+
217+
expect(response.status).toBe(404);
218+
expect(response.body).toMatchObject({
219+
error: {
220+
name: 'NotFoundError',
221+
message: 'Project not found for the "user:default/mock" user.',
222+
},
223+
});
224+
},
225+
LONG_TEST_TIMEOUT,
226+
);
227+
228+
it(
229+
'should return latest init job logs when multiple init jobs exist',
230+
async () => {
231+
await createTestJob(x2aDatabase, {
232+
projectId: project.id,
233+
moduleId: null,
234+
phase: 'init',
235+
status: 'success',
236+
log: 'Old init logs',
237+
});
238+
239+
await new Promise(resolve => setTimeout(resolve, 10));
240+
241+
await createTestJob(x2aDatabase, {
242+
projectId: project.id,
243+
moduleId: null,
244+
phase: 'init',
245+
status: 'success',
246+
log: 'New init logs',
247+
});
248+
249+
const app = await createApp(client);
250+
251+
const response = await request(app)
252+
.get(`/projects/${project.id}/log`)
253+
.send();
254+
255+
expect(response.status).toBe(200);
256+
expect(response.text).toBe('New init logs');
257+
},
258+
LONG_TEST_TIMEOUT,
259+
);
260+
261+
it(
262+
'should return 403 when user has neither x2a.user nor x2a admin permissions',
263+
async () => {
264+
await createTestJob(x2aDatabase, {
265+
projectId: project.id,
266+
moduleId: null,
267+
phase: 'init',
268+
status: 'success',
269+
log: 'Init logs',
270+
});
271+
272+
const app = await createApp(
273+
client,
274+
AuthorizeResult.DENY,
275+
undefined,
276+
undefined,
277+
AuthorizeResult.DENY,
278+
);
279+
280+
const response = await request(app)
281+
.get(`/projects/${project.id}/log`)
282+
.send();
283+
284+
expect(response.status).toBe(403);
285+
expect(response.body).toMatchObject({
286+
error: {
287+
name: 'NotAllowedError',
288+
message: 'The user is not allowed to read projects.',
289+
},
290+
});
291+
},
292+
LONG_TEST_TIMEOUT,
293+
);
294+
},
295+
);
296+
41297
describe.each(supportedDatabaseIds)(
42298
'GET /projects/:projectId/modules/:moduleId/log - %p',
43299
databaseId => {
@@ -56,7 +312,7 @@ describe('createRouter – jobs (log)', () => {
56312

57313
project = await createTestProject(x2aDatabase);
58314
module = await createTestModule(x2aDatabase, project.id);
59-
});
315+
}, LONG_TEST_TIMEOUT);
60316

61317
it(
62318
'should return logs from database for finished job with success status',

workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,87 @@ export function registerJobRoutes(
3434
catalog,
3535
} = deps;
3636

37-
// TODO: Add /projects/:projectId/log
37+
// Log for the init-phase
38+
router.get('/projects/:projectId/log', async (req, res) => {
39+
const endpoint = 'GET /projects/:projectId/log';
40+
const { projectId } = req.params;
41+
const rawStreaming = req.query.streaming as string | boolean | undefined;
42+
const streaming = rawStreaming === 'true' || rawStreaming === true;
3843

44+
logger.info(
45+
`${endpoint} request: projectId=${projectId}, streaming=${streaming}`,
46+
);
47+
48+
// Enforce project permissions
49+
await useEnforceProjectPermissions({
50+
req,
51+
readOnly: true,
52+
projectId,
53+
x2aDatabase,
54+
httpAuth,
55+
permissionsSvc,
56+
catalog,
57+
});
58+
59+
// Get latest init job
60+
const jobs = await x2aDatabase.listJobs({
61+
projectId,
62+
phase: 'init',
63+
lastJobOnly: true,
64+
});
65+
66+
if (jobs.length === 0) {
67+
throw new NotFoundError(
68+
`No init job found for the project projectId=${projectId}`,
69+
);
70+
}
71+
72+
const latestJob = jobs[0]; // Already sorted by started_at DESC in listJobs
73+
74+
// If job is finished, return logs from database
75+
if (latestJob.status === 'success' || latestJob.status === 'error') {
76+
logger.info(
77+
`Job ${latestJob.id} is finished (status: ${latestJob.status}), returning logs from database`,
78+
);
79+
res.setHeader('Content-Type', 'text/plain');
80+
const log = await x2aDatabase.getJobLogs({ jobId: latestJob.id });
81+
if (!log) {
82+
logger.error(`Log not found for a finished job ${latestJob.id}`);
83+
}
84+
res.send(log || '');
85+
return;
86+
}
87+
88+
// Check if job has k8sJobName
89+
if (!latestJob.k8sJobName) {
90+
logger.warn(
91+
`Job ${latestJob.id} has no k8sJobName, returning empty logs`,
92+
);
93+
res.setHeader('Content-Type', 'text/plain');
94+
res.send('');
95+
return;
96+
}
97+
98+
// Get logs from Kubernetes
99+
const logs = await kubeService.getJobLogs(latestJob.k8sJobName, streaming);
100+
101+
// Set content type
102+
res.setHeader('Content-Type', 'text/plain');
103+
104+
// Handle streaming vs non-streaming
105+
if (streaming && typeof logs !== 'string') {
106+
logs.pipe(res);
107+
} else {
108+
res.send(logs as string);
109+
}
110+
});
111+
112+
// Logs for the module-specific phases
39113
router.get('/projects/:projectId/modules/:moduleId/log', async (req, res) => {
40114
const endpoint = 'GET /projects/:projectId/modules/:moduleId/log';
41115
const { projectId, moduleId } = req.params;
42-
const streaming = req.query.streaming === 'true';
116+
const rawStreaming = req.query.streaming as string | boolean | undefined;
117+
const streaming = rawStreaming === 'true' || rawStreaming === true;
43118
const phase = req.query.phase as ModulePhase;
44119

45120
// Validate phase parameter (required)

0 commit comments

Comments
 (0)