Skip to content

Commit a05a067

Browse files
authored
Merge pull request #328 from fractal-analytics-platform/jobs
Created per-user and per-workflow jobs pages
2 parents dfe9e83 + cc4e13b commit a05a067

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1426
-531
lines changed

.env.development

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ AUTH_COOKIE_HTTP_ONLY=true
1111

1212
# PUBLIC VARIABLES (accessible from client side)
1313
PUBLIC_FRACTAL_ADMIN_SUPPORT_EMAIL=help@localhost
14-
#PUBLIC_OAUTH_PROVIDER=github
14+
PUBLIC_UPDATE_JOBS_INTERVAL=3000
15+
#PUBLIC_OAUTH_CLIENT_NAME=github

CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
*Note: Numbers like (\#123) point to closed Pull Requests on the fractal-web repository.*
22

3-
# Unreleased
3+
# 0.7.0 (unreleased)
44

5-
This release improves and extends the login/logout features. Using OAuth
6-
authentication requires fractal-server >1.3.13 (due to the recent addition of
7-
the fractal-server `redirect_url` environment variable).
5+
This release requires fractal-server 1.4.0.
86

7+
* Aligned with fractal-server 1.4.0 API, including trailing slash for endpoints' paths (\#328).
8+
* Added spinner during page loading (\#328).
9+
* Job pages:
10+
* Created per-user and per-workflow jobs pages (\#328).
11+
* Removed per-project and per-workflow jobs pages (\#328).
12+
* Added automatic background update job pages (\#328).
913
* Improved handling of session expiration (\#333).
1014
* Fixed logout bug (\#327).
1115
* Implemented OAuth2 login (\#333).

__tests__/JobsList.test.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { fireEvent, render } from '@testing-library/svelte';
3+
import { readable } from 'svelte/store';
4+
import { data } from './mock/jobs-list';
5+
6+
// Mocking the page store
7+
vi.mock('$app/stores', () => {
8+
return {
9+
page: readable({
10+
data
11+
})
12+
};
13+
});
14+
// Mocking public variables
15+
vi.mock('$env/dynamic/public', () => {
16+
return { env: {} };
17+
});
18+
19+
// Mocking fetch
20+
global.fetch = vi.fn();
21+
22+
// Mocking window location
23+
delete window.location;
24+
global.window.location = {
25+
reload: vi.fn()
26+
};
27+
28+
// The component to be tested must be imported after the mock setup
29+
import JobsList from '../src/lib/components/jobs/JobsList.svelte';
30+
31+
describe('JobsList', () => {
32+
it('display, filter and sort jobs', async () => {
33+
const nop = function () {};
34+
const result = render(JobsList, {
35+
props: { jobUpdater: nop }
36+
});
37+
let table = result.getByRole('table');
38+
expect(table.querySelectorAll('tbody tr').length).eq(3);
39+
40+
const filters = result.getAllByRole('combobox');
41+
42+
const projectFilter = filters[0];
43+
verifyOptions(projectFilter, ['', '1', '2']);
44+
const workflowFilter = filters[1];
45+
verifyOptions(workflowFilter, ['', '1', '2']);
46+
const inputDatasetFilter = filters[2];
47+
verifyOptions(inputDatasetFilter, ['', '1', '3', '5']);
48+
const outputDatasetFilter = filters[3];
49+
verifyOptions(outputDatasetFilter, ['', '2', '4', '6']);
50+
const statusFilter = filters[4];
51+
verifyOptions(statusFilter, ['', 'running', 'done', 'failed', 'submitted']);
52+
53+
// Filter by project
54+
await fireEvent.change(projectFilter, { target: { value: '1' } });
55+
table = result.getByRole('table');
56+
expect(table.querySelectorAll('tbody tr').length).eq(1);
57+
expect(table.querySelectorAll('tbody tr td')[5].textContent).eq('input1');
58+
await clearFilters(result);
59+
60+
// Filter by workflow
61+
await fireEvent.change(workflowFilter, { target: { value: '2' } });
62+
table = result.getByRole('table');
63+
expect(table.querySelectorAll('tbody tr').length).eq(2);
64+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[5].textContent).eq('input3');
65+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[5].textContent).eq('input2');
66+
await clearFilters(result);
67+
68+
// Filter by input dataset
69+
await fireEvent.change(inputDatasetFilter, { target: { value: '3' } });
70+
table = result.getByRole('table');
71+
expect(table.querySelectorAll('tbody tr').length).eq(1);
72+
expect(table.querySelectorAll('tbody tr td')[5].textContent).eq('input2');
73+
await clearFilters(result);
74+
75+
// Filter by output dataset
76+
await fireEvent.change(outputDatasetFilter, { target: { value: '4' } });
77+
table = result.getByRole('table');
78+
expect(table.querySelectorAll('tbody tr').length).eq(1);
79+
expect(table.querySelectorAll('tbody tr td')[6].textContent).eq('output2');
80+
await clearFilters(result);
81+
82+
// Filter by job status
83+
await fireEvent.change(statusFilter, { target: { value: 'running' } });
84+
table = result.getByRole('table');
85+
expect(table.querySelectorAll('tbody tr').length).eq(1);
86+
expect(table.querySelectorAll('tbody tr td')[5].textContent).eq('input3');
87+
await clearFilters(result);
88+
89+
// Verify default sorting
90+
table = result.getByRole('table');
91+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[1].textContent).eq(
92+
'10/30/2023, 9:30:38 AM'
93+
);
94+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[1].textContent).eq(
95+
'10/30/2023, 9:15:38 AM'
96+
);
97+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[1].textContent).eq(
98+
'10/30/2023, 9:00:38 AM'
99+
);
100+
101+
// Sort by start date
102+
const startDateSorter = table.querySelector('thead th:nth-child(2)');
103+
await fireEvent.click(startDateSorter);
104+
table = result.getByRole('table');
105+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[1].textContent).eq(
106+
'10/30/2023, 9:00:38 AM'
107+
);
108+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[1].textContent).eq(
109+
'10/30/2023, 9:15:38 AM'
110+
);
111+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[1].textContent).eq(
112+
'10/30/2023, 9:30:38 AM'
113+
);
114+
});
115+
116+
async function clearFilters(result) {
117+
const clearFiltersBtn = result.getByRole('button', { name: 'Clear filters' });
118+
await fireEvent.click(clearFiltersBtn);
119+
}
120+
121+
it('refresh jobs', async () => {
122+
const jobUpdater = function () {
123+
return data.jobs.map((j) => (j.status === 'running' ? { ...j, status: 'done' } : j));
124+
};
125+
const result = render(JobsList, {
126+
props: { jobUpdater }
127+
});
128+
let table = result.getByRole('table');
129+
expect(table.querySelectorAll('tbody tr').length).eq(3);
130+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[7].textContent).eq('running');
131+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[7].textContent).eq('failed');
132+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[7].textContent).eq('done');
133+
134+
const refreshButton = result.getByRole('button', { name: 'Refresh' });
135+
await fireEvent.click(refreshButton);
136+
137+
table = result.getByRole('table');
138+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[7].textContent).eq('done');
139+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[7].textContent).eq('failed');
140+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[7].textContent).eq('done');
141+
});
142+
143+
it('cancel job', async () => {
144+
const nop = function () {};
145+
const result = render(JobsList, {
146+
props: { jobUpdater: nop }
147+
});
148+
let table = result.getByRole('table');
149+
expect(table.querySelectorAll('tbody tr').length).eq(3);
150+
151+
fetch.mockResolvedValue({ ok: true });
152+
153+
const cancelButton = result.getByRole('button', { name: 'Cancel' });
154+
await fireEvent.click(cancelButton);
155+
await new Promise(setTimeout);
156+
157+
expect(window.location.reload).toHaveBeenCalledOnce();
158+
});
159+
160+
it('error while cancelling job', async () => {
161+
const nop = function () {};
162+
const result = render(JobsList, {
163+
props: { jobUpdater: nop }
164+
});
165+
let table = result.getByRole('table');
166+
expect(table.querySelectorAll('tbody tr').length).eq(3);
167+
168+
expect(result.queryAllByRole('alert').length).eq(0);
169+
170+
fetch.mockResolvedValue({
171+
ok: false,
172+
json: () => new Promise((resolve) => resolve({ error: 'not implemented' }))
173+
});
174+
175+
const cancelButton = result.getByRole('button', { name: 'Cancel' });
176+
await fireEvent.click(cancelButton);
177+
await new Promise(setTimeout);
178+
179+
expect(result.queryAllByRole('alert').length).eq(1);
180+
});
181+
182+
it('updates jobs in background', async () => {
183+
vi.useFakeTimers();
184+
try {
185+
const jobUpdater = function () {
186+
return data.jobs.map((j) => (j.status === 'running' ? { ...j, status: 'done' } : j));
187+
};
188+
const result = render(JobsList, {
189+
props: { jobUpdater }
190+
});
191+
let table = result.getByRole('table');
192+
expect(table.querySelectorAll('tbody tr').length).eq(3);
193+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[7].textContent).eq('running');
194+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[7].textContent).eq('failed');
195+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[7].textContent).eq('done');
196+
197+
vi.advanceTimersByTime(3500);
198+
vi.useRealTimers();
199+
// trigger table update
200+
await new Promise(setTimeout);
201+
202+
table = result.getByRole('table');
203+
expect(table.querySelectorAll('tbody tr:nth-child(1) td')[7].textContent).eq('done');
204+
expect(table.querySelectorAll('tbody tr:nth-child(2) td')[7].textContent).eq('failed');
205+
expect(table.querySelectorAll('tbody tr:nth-child(3) td')[7].textContent).eq('done');
206+
} finally {
207+
vi.useRealTimers();
208+
}
209+
});
210+
});
211+
212+
function verifyOptions(element, expectedOptions) {
213+
const options = element.querySelectorAll('option');
214+
expect(options.length).eq(expectedOptions.length);
215+
for (let i = 0; i < options.length; i++) {
216+
expect(options[i].value).eq(expectedOptions[i]);
217+
}
218+
}

__tests__/component_utilities.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { it, expect } from 'vitest';
2-
import { orderTasksByOwnerThenByNameThenByVersion } from '$lib/common/component_utilities.js';
2+
import {
3+
orderTasksByOwnerThenByNameThenByVersion,
4+
removeDuplicatedItems
5+
} from '$lib/common/component_utilities.js';
36

47
it('should order tasks by owner, then by name, then by version', () => {
58
const tasks = [
@@ -42,3 +45,21 @@ it('should order tasks by owner, then by name, then by version', () => {
4245
{ name: 'task9', owner: 'owner3', version: '0.0.2' }
4346
]);
4447
});
48+
49+
it('removes duplicated datasets and sort by name', () => {
50+
const allDatasets = [
51+
{ id: 2, name: 'output' },
52+
{ id: 1, name: 'input' },
53+
{ id: 3, name: 'test' },
54+
{ id: 1, name: 'input' },
55+
{ id: 2, name: 'output' }
56+
];
57+
const datasets = removeDuplicatedItems(allDatasets);
58+
expect(datasets.length).toEqual(3);
59+
expect(datasets[0].id).toEqual(1);
60+
expect(datasets[0].name).toEqual('input');
61+
expect(datasets[1].id).toEqual(2);
62+
expect(datasets[1].name).toEqual('output');
63+
expect(datasets[2].id).toEqual(3);
64+
expect(datasets[2].name).toEqual('test');
65+
});

__tests__/mock/jobs-list.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
export const data = {
2+
projects: [
3+
{
4+
id: 1,
5+
name: 'project 1'
6+
},
7+
{
8+
id: 2,
9+
name: 'project 2'
10+
}
11+
],
12+
inputDatasets: [
13+
{ id: 1, name: 'input1' },
14+
{ id: 3, name: 'input2' },
15+
{ id: 5, name: 'input3' }
16+
],
17+
outputDatasets: [
18+
{ id: 2, name: 'output1' },
19+
{ id: 4, name: 'output2' },
20+
{ id: 6, name: 'output3' }
21+
],
22+
workflows: [
23+
{
24+
id: 1,
25+
name: 'workflow 1'
26+
},
27+
{
28+
id: 2,
29+
name: 'workflow 2'
30+
}
31+
],
32+
jobs: [
33+
{
34+
id: 1,
35+
project_id: 1,
36+
workflow_id: 1,
37+
input_dataset_id: 1,
38+
output_dataset_id: 2,
39+
start_timestamp: '2023-10-30T09:00:38.442196',
40+
end_timestamp: '2023-10-30T09:10:38.442196',
41+
status: 'done'
42+
},
43+
{
44+
id: 2,
45+
project_id: 2,
46+
workflow_id: 2,
47+
input_dataset_id: 3,
48+
output_dataset_id: 4,
49+
start_timestamp: '2023-10-30T09:15:38.442196',
50+
end_timestamp: '2023-10-30T09:20:38.442196',
51+
status: 'failed'
52+
},
53+
{
54+
id: 3,
55+
project_id: 2,
56+
workflow_id: 2,
57+
input_dataset_id: 5,
58+
output_dataset_id: 6,
59+
start_timestamp: '2023-10-30T09:30:38.442196',
60+
end_timestamp: null,
61+
status: 'running'
62+
}
63+
]
64+
};

package-lock.json

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)