Skip to content

Commit 321f6dd

Browse files
authored
feat(x2a): log streaming and copy*paste without row numbers (#2927)
Stream logs in real-time as backend jobs produce them. Exclude row numbers from copy selection. Made-with: Cursor Signed-off-by: Marek Libra <marek.libra@gmail.com>
1 parent 20bd103 commit 321f6dd

21 files changed

Lines changed: 1434 additions & 53 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch
3+
'@red-hat-developer-hub/backstage-plugin-x2a': patch
4+
---
5+
6+
Stream logs in real-time as backend jobs produce them. Exclude row numbers from copy selection.

workspaces/x2a/packages/app/src/setupTests.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const suppressPatterns = [
2727
'findDOMNode is deprecated', // @material-ui/core
2828
'validateDOMNesting', // React DOM nesting (e.g. div inside p from Typography)
2929
'Not implemented: HTMLCanvasElement.prototype.getContext', // JSDOM canvas
30+
'DEPRECATION WARNING', // @backstage/frontend-plugin-api config.schema → configSchema
3031
];
3132
const getMatchedPattern = (args: unknown[]): string | null => {
3233
const first = args[0];

workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jest.mock('./services/makeK8sClient', () => ({
4343
makeK8sClient: jest.fn(() => ({
4444
coreV1Api: {},
4545
batchV1Api: {},
46+
kubeConfig: { getCurrentCluster: () => ({ server: 'https://mock' }) },
4647
})),
4748
}));
4849

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,54 @@ describe('createRouter – jobs (log)', () => {
219219
LONG_TEST_TIMEOUT,
220220
);
221221

222+
it(
223+
'should return 200 with empty body when streaming and kube has no log data yet (e.g. no pod or Pending)',
224+
async () => {
225+
await createTestJob(x2aDatabase, {
226+
projectId: project.id,
227+
moduleId: null,
228+
phase: 'init',
229+
status: 'running',
230+
k8sJobName: 'init-k8s-job',
231+
});
232+
const mockGetJobLogs = jest.fn().mockResolvedValue('');
233+
const app = await createApp(client, undefined, undefined, {
234+
getJobLogs: mockGetJobLogs,
235+
});
236+
const response = await request(app)
237+
.get(`/projects/${project.id}/log?streaming=true`)
238+
.send();
239+
expect(response.status).toBe(200);
240+
expect(response.type).toBe('text/plain');
241+
expect(response.text).toBe('');
242+
expect(mockGetJobLogs).toHaveBeenCalledWith('init-k8s-job', true);
243+
},
244+
LONG_TEST_TIMEOUT,
245+
);
246+
247+
it(
248+
'should not call getJobLogs for streaming when init job has no k8sJobName yet',
249+
async () => {
250+
await createTestJob(x2aDatabase, {
251+
projectId: project.id,
252+
moduleId: null,
253+
phase: 'init',
254+
status: 'pending',
255+
});
256+
const mockGetJobLogs = jest.fn();
257+
const app = await createApp(client, undefined, undefined, {
258+
getJobLogs: mockGetJobLogs,
259+
});
260+
const response = await request(app)
261+
.get(`/projects/${project.id}/log?streaming=true`)
262+
.send();
263+
expect(response.status).toBe(200);
264+
expect(response.text).toBe('');
265+
expect(mockGetJobLogs).not.toHaveBeenCalled();
266+
},
267+
LONG_TEST_TIMEOUT,
268+
);
269+
222270
it(
223271
'should return 404 when no init job found for project',
224272
async () => {

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ async function sendJobLogs(
3434
deps: Pick<RouterDeps, 'x2aDatabase' | 'kubeService' | 'logger'>,
3535
): Promise<void> {
3636
const { x2aDatabase, kubeService, logger } = deps;
37+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
3738

3839
if (
3940
job.status === 'success' ||
@@ -43,7 +44,6 @@ async function sendJobLogs(
4344
logger.info(
4445
`Job ${job.id} is finished (status: ${job.status}), returning logs from database`,
4546
);
46-
res.setHeader('Content-Type', 'text/plain');
4747
const log = await x2aDatabase.getJobLogs({ jobId: job.id });
4848
if (!log) {
4949
logger.error(`Log not found for a finished job ${job.id}`);
@@ -54,14 +54,15 @@ async function sendJobLogs(
5454

5555
if (!job.k8sJobName) {
5656
logger.warn(`Job ${job.id} has no k8sJobName, returning empty logs`);
57-
res.setHeader('Content-Type', 'text/plain');
5857
res.send('');
5958
return;
6059
}
6160

6261
const logs = await kubeService.getJobLogs(job.k8sJobName, streaming);
63-
res.setHeader('Content-Type', 'text/plain');
6462
if (streaming && typeof logs !== 'string') {
63+
// Hints to proxies (e.g. nginx, OpenShift route) not to buffer the body.
64+
res.setHeader('Cache-Control', 'no-store, no-transform, must-revalidate');
65+
res.setHeader('X-Accel-Buffering', 'no');
6566
const stream = logs as Readable;
6667
stream.on('error', err => {
6768
logger.error(

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,57 @@ describe('createRouter – jobs (module log)', () => {
196196
LONG_TEST_TIMEOUT,
197197
);
198198

199+
it(
200+
'should return 200 with empty body when streaming and kube has no log data yet',
201+
async () => {
202+
await createTestJob(x2aDatabase, {
203+
projectId: project.id,
204+
moduleId: module.id,
205+
phase: 'analyze',
206+
status: 'running',
207+
k8sJobName: 'test-k8s-job',
208+
});
209+
const mockGetJobLogs = jest.fn().mockResolvedValue('');
210+
const app = await createApp(client, undefined, undefined, {
211+
getJobLogs: mockGetJobLogs,
212+
});
213+
const response = await request(app)
214+
.get(
215+
`/projects/${project.id}/modules/${module.id}/log?phase=analyze&streaming=true`,
216+
)
217+
.send();
218+
expect(response.status).toBe(200);
219+
expect(response.text).toBe('');
220+
expect(mockGetJobLogs).toHaveBeenCalledWith('test-k8s-job', true);
221+
},
222+
LONG_TEST_TIMEOUT,
223+
);
224+
225+
it(
226+
'should not call getJobLogs for module streaming when job has no k8sJobName yet',
227+
async () => {
228+
await createTestJob(x2aDatabase, {
229+
projectId: project.id,
230+
moduleId: module.id,
231+
phase: 'publish',
232+
status: 'pending',
233+
});
234+
const mockGetJobLogs = jest.fn();
235+
const app = await createApp(client, undefined, undefined, {
236+
getJobLogs: mockGetJobLogs,
237+
});
238+
const response = await request(app)
239+
.get(
240+
`/projects/${project.id}/modules/${module.id}/log?phase=publish&streaming=true`,
241+
)
242+
.send();
243+
expect(response.status).toBe(200);
244+
expect(response.text).toBe('');
245+
expect(mockGetJobLogs).not.toHaveBeenCalled();
246+
},
247+
LONG_TEST_TIMEOUT,
248+
);
249+
199250
it(
200251
'should return 400 when phase parameter is missing',
201252
async () => {

0 commit comments

Comments
 (0)