diff --git a/cypress/integration/vault.spec.js b/cypress/integration/vault.spec.js
index 41d5861b..43c34704 100644
--- a/cypress/integration/vault.spec.js
+++ b/cypress/integration/vault.spec.js
@@ -1,454 +1,521 @@
/**
* Copyright (C) 2019-2020 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
let vaultItems = [];
const progressbarColors = {
'new': 'rgba(128, 128, 128, 0.5)',
'pending': 'rgba(0, 0, 255, 0.5)',
'done': 'rgb(92, 184, 92)'
};
function checkVaultCookingTask(objectType) {
cy.contains('button', 'Download')
.click();
cy.contains('.dropdown-item', objectType)
.click();
cy.wait('@checkVaultCookingTask');
}
function updateVaultItemList(vaultUrl, vaultItems) {
cy.visit(vaultUrl)
.then(() => {
// Add uncooked task to localStorage
// which updates it in vault items list
window.localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultItems));
});
}
// Mocks API response : /api/1/vault/(:objectType)/(:hash)
// objectType : {'directory', 'revision'}
function genVaultCookingResponse(objectType, objectId, status, message, fetchUrl) {
return {
'obj_type': objectType,
'id': 1,
'progress_message': message,
'status': status,
'obj_id': objectId,
'fetch_url': fetchUrl
};
};
// Tests progressbar color, status
// And status in localStorage
function testStatus(taskId, color, statusMsg, status) {
cy.get(`.swh-vault-table #vault-task-${taskId}`)
.should('be.visible')
.find('.progress-bar')
.should('be.visible')
.and('have.css', 'background-color', color)
.and('contain', statusMsg)
.then(() => {
// Vault item with object_id as taskId should exist in localStorage
const currentVaultItems = JSON.parse(window.localStorage.getItem('swh-vault-cooking-tasks'));
const vaultItem = currentVaultItems.find(obj => obj.object_id === taskId);
assert.isNotNull(vaultItem);
assert.strictEqual(vaultItem.status, status);
});
}
describe('Vault Cooking User Interface Tests', function() {
before(function() {
const dirInfo = this.origin[0].directory[0];
this.directory = dirInfo.id;
this.directoryUrl = this.Urls.browse_origin_directory() +
`?origin_url=${this.origin[0].url}&path=${dirInfo.path}`;
this.vaultDirectoryUrl = this.Urls.api_1_vault_cook_directory(this.directory);
this.vaultFetchDirectoryUrl = this.Urls.api_1_vault_fetch_directory(this.directory);
this.revision = this.origin[1].revisions[0];
this.revisionUrl = this.Urls.browse_revision(this.revision);
this.vaultRevisionUrl = this.Urls.api_1_vault_cook_revision_gitfast(this.revision);
this.vaultFetchRevisionUrl = this.Urls.api_1_vault_fetch_revision_gitfast(this.revision);
vaultItems[0] = {
'object_type': 'revision',
'object_id': this.revision,
'email': '',
'status': 'done',
'fetch_url': `/api/1/vault/revision/${this.revision}/gitfast/raw/`,
'progress_message': null
};
});
beforeEach(function() {
this.genVaultDirCookingResponse = (status, message = null) => {
return genVaultCookingResponse('directory', this.directory, status,
message, this.vaultFetchDirectoryUrl);
};
this.genVaultRevCookingResponse = (status, message = null) => {
return genVaultCookingResponse('revision', this.revision, status,
message, this.vaultFetchRevisionUrl);
};
cy.server();
});
- it('should create a directory cooking task and report its status', function() {
+ it('should report an error when vault service is experiencing issues', function() {
+ // Browse a directory
+ cy.visit(this.directoryUrl);
+
+ // Stub responses when requesting the vault API to simulate
+ // an internal server error
+ cy.route({
+ method: 'GET',
+ url: this.vaultDirectoryUrl,
+ response: {'exception': 'APIError'},
+ status: 500
+ }).as('checkVaultCookingTask');
+
+ cy.contains('button', 'Download')
+ .click();
+
+ // Check error alert is displayed
+ cy.get('.alert-danger')
+ .should('be.visible')
+ .should('contain', 'Archive cooking service is currently experiencing issues.');
+ });
+
+ it('should report an error when a cooking task creation failed', function() {
// Browse a directory
cy.visit(this.directoryUrl);
// Stub responses when requesting the vault API to simulate
- // a task has been created
+ // a task can not be created
+ cy.route({
+ method: 'GET',
+ url: this.vaultDirectoryUrl,
+ response: {'exception': 'NotFoundExc'}
+ }).as('checkVaultCookingTask');
+ cy.route({
+ method: 'POST',
+ url: this.vaultDirectoryUrl,
+ response: {'exception': 'ValueError'},
+ status: 500
+ }).as('createVaultCookingTask');
+
+ cy.contains('button', 'Download')
+ .click();
+
+ // Create a vault cooking task through the GUI
+ cy.get('.modal-dialog')
+ .contains('button:visible', 'Ok')
+ .click();
+
+ cy.wait('@createVaultCookingTask');
+
+ // Check error alert is displayed
+ cy.get('.alert-danger')
+ .should('be.visible')
+ .should('contain', 'Archive cooking request submission failed.');
+ });
+
+ it('should create a directory cooking task and report the success', function() {
+
+ // Browse a directory
+ cy.visit(this.directoryUrl);
+
+ // Stub responses when requesting the vault API to simulate
+ // a task has been created
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: {'exception': 'NotFoundExc'}
}).as('checkVaultCookingTask');
cy.route({
method: 'POST',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('new')
}).as('createVaultCookingTask');
cy.contains('button', 'Download')
.click();
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('new')
}).as('checkVaultCookingTask');
cy.window().then(win => {
const swhIdsContext = win.swh.webapp.getSwhIdsContext();
const browseDirectoryUrl = swhIdsContext.directory.swhid_with_context_url;
// Create a vault cooking task through the GUI
cy.get('.modal-dialog')
.contains('button:visible', 'Ok')
.click();
cy.wait('@createVaultCookingTask');
- // Check that a redirection to the vault UI has been performed
- cy.url().should('eq', Cypress.config().baseUrl + this.Urls.browse_vault());
+ // Check success alert is displayed
+ cy.get('.alert-success')
+ .should('be.visible')
+ .should('contain', 'Archive cooking request successfully submitted.');
+
+ // Go to Downloads page
+ cy.visit(this.Urls.browse_vault());
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.directory, progressbarColors['new'], 'new', 'new');
});
// Stub response to the vault API indicating the task is processing
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('pending', 'Processing...')
}).as('checkVaultCookingTask');
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.directory, progressbarColors['pending'], 'Processing...', 'pending');
});
// Stub response to the vault API indicating the task is finished
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('done')
}).as('checkVaultCookingTask');
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.directory, progressbarColors['done'], 'done', 'done');
});
// Stub response to the vault API to simulate archive download
cy.route({
method: 'GET',
url: this.vaultFetchDirectoryUrl,
response: `fx:${this.directory}.tar.gz,binary`,
headers: {
'Content-disposition': `attachment; filename=${this.directory}.tar.gz`,
'Content-Type': 'application/gzip'
}
}).as('fetchCookedArchive');
cy.get(`#vault-task-${this.directory} .vault-origin a`)
.should('have.text', this.origin[0].url)
.should('have.attr', 'href', `${this.Urls.browse_origin()}?origin_url=${this.origin[0].url}`);
cy.get(`#vault-task-${this.directory} .vault-object-info a`)
.should('have.text', this.directory)
.should('have.attr', 'href', browseDirectoryUrl);
cy.get(`#vault-task-${this.directory} .vault-dl-link button`)
.click();
cy.wait('@fetchCookedArchive').then((xhr) => {
assert.isNotNull(xhr.response.body);
});
});
});
it('should create a revision cooking task and report its status', function() {
cy.adminLogin();
// Browse a revision
cy.visit(this.revisionUrl);
// Stub responses when requesting the vault API to simulate
// a task has been created
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: {'exception': 'NotFoundExc'}
}).as('checkVaultCookingTask');
cy.route({
method: 'POST',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('new')
}).as('createVaultCookingTask');
// Create a vault cooking task through the GUI
checkVaultCookingTask('as git');
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('new')
}).as('checkVaultCookingTask');
cy.window().then(win => {
const swhIdsContext = win.swh.webapp.getSwhIdsContext();
const browseRevisionUrl = swhIdsContext.revision.swhid_url;
// Create a vault cooking task through the GUI
cy.get('.modal-dialog')
.contains('button:visible', 'Ok')
.click();
cy.wait('@createVaultCookingTask');
- // Check that a redirection to the vault UI has been performed
- cy.url().should('eq', Cypress.config().baseUrl + this.Urls.browse_vault());
+ // Check success alert is displayed
+ cy.get('.alert-success')
+ .should('be.visible')
+ .should('contain', 'Archive cooking request successfully submitted.');
+
+ // Go to Downloads page
+ cy.visit(this.Urls.browse_vault());
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.revision, progressbarColors['new'], 'new', 'new');
});
// Stub response to the vault API indicating the task is processing
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('pending', 'Processing...')
}).as('checkVaultCookingTask');
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.revision, progressbarColors['pending'], 'Processing...', 'pending');
});
// Stub response to the vault API indicating the task is finished
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('done')
}).as('checkVaultCookingTask');
cy.wait('@checkVaultCookingTask').then(() => {
testStatus(this.revision, progressbarColors['done'], 'done', 'done');
});
// Stub response to the vault API indicating to simulate archive
// download
cy.route({
method: 'GET',
url: this.vaultFetchRevisionUrl,
response: `fx:${this.revision}.gitfast.gz,binary`,
headers: {
'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`,
'Content-Type': 'application/gzip'
}
}).as('fetchCookedArchive');
cy.get(`#vault-task-${this.revision} .vault-origin`)
.should('have.text', 'unknown');
cy.get(`#vault-task-${this.revision} .vault-object-info a`)
.should('have.text', this.revision)
.should('have.attr', 'href', browseRevisionUrl);
cy.get(`#vault-task-${this.revision} .vault-dl-link button`)
.click();
cy.wait('@fetchCookedArchive').then((xhr) => {
assert.isNotNull(xhr.response.body);
});
});
});
it('should offer to recook an archive if no more available to download', function() {
updateVaultItemList(this.Urls.browse_vault(), vaultItems);
// Send 404 when fetching vault item
cy.route({
method: 'GET',
status: 404,
url: this.vaultFetchRevisionUrl,
response: {
'exception': 'NotFoundExc',
'reason': `Revision with ID '${this.revision}' not found.`
},
headers: {
'Content-Type': 'json'
}
}).as('fetchCookedArchive');
cy.get(`#vault-task-${this.revision} .vault-dl-link button`)
.click();
cy.wait('@fetchCookedArchive').then(() => {
cy.route({
method: 'POST',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('new')
}).as('createVaultCookingTask');
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('new')
}).as('checkVaultCookingTask');
cy.get('#vault-recook-object-modal > .modal-dialog')
.should('be.visible')
.contains('button:visible', 'Ok')
.click();
cy.wait('@createVaultCookingTask')
.wait('@checkVaultCookingTask')
.then(() => {
testStatus(this.revision, progressbarColors['new'], 'new', 'new');
});
});
});
it('should remove selected vault items', function() {
updateVaultItemList(this.Urls.browse_vault(), vaultItems);
cy.get(`#vault-task-${this.revision}`)
.find('input[type="checkbox"]')
.click({force: true});
cy.contains('button', 'Remove selected tasks')
.click();
cy.get(`#vault-task-${this.revision}`)
.should('not.exist');
});
it('should offer to immediately download a directory tarball if already cooked', function() {
// Browse a directory
cy.visit(this.directoryUrl);
// Stub responses when requesting the vault API to simulate
// the directory tarball has already been cooked
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('done')
}).as('checkVaultCookingTask');
// Stub response to the vault API to simulate archive download
cy.route({
method: 'GET',
url: this.vaultFetchDirectoryUrl,
response: `fx:${this.directory}.tar.gz,binary`,
headers: {
'Content-disposition': `attachment; filename=${this.directory}.tar.gz`,
'Content-Type': 'application/gzip'
}
}).as('fetchCookedArchive');
// Create a vault cooking task through the GUI
cy.contains('button', 'Download')
.click();
// Start archive download through the GUI
cy.get('.modal-dialog')
.contains('button:visible', 'Ok')
.click();
cy.wait('@fetchCookedArchive');
});
it('should offer to immediately download a revision gitfast archive if already cooked', function() {
cy.adminLogin();
// Browse a directory
cy.visit(this.revisionUrl);
// Stub responses when requesting the vault API to simulate
// the directory tarball has already been cooked
cy.route({
method: 'GET',
url: this.vaultRevisionUrl,
response: this.genVaultRevCookingResponse('done')
}).as('checkVaultCookingTask');
// Stub response to the vault API to simulate archive download
cy.route({
method: 'GET',
url: this.vaultFetchRevisionUrl,
response: `fx:${this.revision}.gitfast.gz,binary`,
headers: {
'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`,
'Content-Type': 'application/gzip'
}
}).as('fetchCookedArchive');
checkVaultCookingTask('as git');
// Start archive download through the GUI
cy.get('.modal-dialog')
.contains('button:visible', 'Ok')
.click();
cy.wait('@fetchCookedArchive');
});
it('should offer to recook an object if previous vault task failed', function() {
cy.visit(this.directoryUrl);
// Stub responses when requesting the vault API to simulate
// the last cooking of the directory tarball has failed
cy.route({
method: 'GET',
url: this.vaultDirectoryUrl,
response: this.genVaultDirCookingResponse('failed')
}).as('checkVaultCookingTask');
cy.contains('button', 'Download')
.click();
// Check that recooking the directory is offered to user
cy.get('.modal-dialog')
.contains('button:visible', 'Ok')
.should('be.visible');
});
});
diff --git a/swh/web/assets/src/bundles/vault/vault-create-tasks.js b/swh/web/assets/src/bundles/vault/vault-create-tasks.js
index e0c35702..5fab1b1c 100644
--- a/swh/web/assets/src/bundles/vault/vault-create-tasks.js
+++ b/swh/web/assets/src/bundles/vault/vault-create-tasks.js
@@ -1,134 +1,161 @@
/**
* Copyright (C) 2018-2019 The Software Heritage developers
* See the AUTHORS file at the top-level directory of this distribution
* License: GNU Affero General Public License version 3, or any later version
* See top-level LICENSE file for more information
*/
-import {handleFetchError, csrfPost} from 'utils/functions';
+import {handleFetchError, csrfPost, htmlAlert} from 'utils/functions';
+
+const alertStyle = {
+ 'position': 'fixed',
+ 'left': '1rem',
+ 'bottom': '1rem',
+ 'z-index': '100000'
+};
export function vaultRequest(objectType, objectId) {
let vaultUrl;
if (objectType === 'directory') {
vaultUrl = Urls.api_1_vault_cook_directory(objectId);
} else {
vaultUrl = Urls.api_1_vault_cook_revision_gitfast(objectId);
}
// check if object has already been cooked
fetch(vaultUrl)
.then(response => response.json())
.then(data => {
// object needs to be cooked
if (data.exception === 'NotFoundExc' || data.status === 'failed') {
// if last cooking has failed, remove previous task info from localStorage
// in order to force the recooking of the object
swh.vault.removeCookingTaskInfo([objectId]);
$(`#vault-cook-${objectType}-modal`).modal('show');
// object has been cooked and should be in the vault cache,
// it will be asked to cook it again if it is not
} else if (data.status === 'done') {
$(`#vault-fetch-${objectType}-modal`).modal('show');
+ } else {
+ const cookingServiceDownAlert =
+ $(htmlAlert('danger',
+ 'Archive cooking service is currently experiencing issues.
' +
+ 'Please try again later.',
+ true));
+ cookingServiceDownAlert.css(alertStyle);
+ $('body').append(cookingServiceDownAlert);
}
});
}
function addVaultCookingTask(cookingTask) {
const swhidsContext = swh.webapp.getSwhIdsContext();
cookingTask.origin = swhidsContext[cookingTask.object_type].context.origin;
cookingTask.path = swhidsContext[cookingTask.object_type].context.path;
cookingTask.browse_url = swhidsContext[cookingTask.object_type].swhid_with_context_url;
if (!cookingTask.browse_url) {
cookingTask.browse_url = swhidsContext[cookingTask.object_type].swhid_url;
}
let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks'));
if (!vaultCookingTasks) {
vaultCookingTasks = [];
}
if (vaultCookingTasks.find(val => {
return val.object_type === cookingTask.object_type &&
val.object_id === cookingTask.object_id;
}) === undefined) {
let cookingUrl;
if (cookingTask.object_type === 'directory') {
cookingUrl = Urls.api_1_vault_cook_directory(cookingTask.object_id);
} else {
cookingUrl = Urls.api_1_vault_cook_revision_gitfast(cookingTask.object_id);
}
if (cookingTask.email) {
cookingUrl += '?email=' + cookingTask.email;
}
+
csrfPost(cookingUrl)
.then(handleFetchError)
.then(() => {
vaultCookingTasks.push(cookingTask);
localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks));
$('#vault-cook-directory-modal').modal('hide');
$('#vault-cook-revision-modal').modal('hide');
- window.location = Urls.browse_vault();
+ const cookingTaskCreatedAlert =
+ $(htmlAlert('success',
+ 'Archive cooking request successfully submitted.
' +
+ `Go to the Downloads page ` +
+ 'to get the download link once it is ready.',
+ true));
+ cookingTaskCreatedAlert.css(alertStyle);
+ $('body').append(cookingTaskCreatedAlert);
})
.catch(() => {
$('#vault-cook-directory-modal').modal('hide');
$('#vault-cook-revision-modal').modal('hide');
+ const cookingTaskFailedAlert =
+ $(htmlAlert('danger',
+ 'Archive cooking request submission failed.',
+ true));
+ cookingTaskFailedAlert.css(alertStyle);
+ $('body').append(cookingTaskFailedAlert);
});
- } else {
- window.location = Urls.browse_vault();
}
}
function validateEmail(email) {
let re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
}
export function cookDirectoryArchive(directoryId) {
let email = $('#swh-vault-directory-email').val().trim();
if (!email || validateEmail(email)) {
let cookingTask = {
'object_type': 'directory',
'object_id': directoryId,
'email': email,
'status': 'new'
};
addVaultCookingTask(cookingTask);
} else {
$('#invalid-email-modal').modal('show');
}
}
export function fetchDirectoryArchive(directoryId) {
$('#vault-fetch-directory-modal').modal('hide');
const vaultUrl = Urls.api_1_vault_cook_directory(directoryId);
fetch(vaultUrl)
.then(response => response.json())
.then(data => {
swh.vault.fetchCookedObject(data.fetch_url);
});
}
export function cookRevisionArchive(revisionId) {
let email = $('#swh-vault-revision-email').val().trim();
if (!email || validateEmail(email)) {
let cookingTask = {
'object_type': 'revision',
'object_id': revisionId,
'email': email,
'status': 'new'
};
addVaultCookingTask(cookingTask);
} else {
$('#invalid-email-modal').modal('show');
}
}
export function fetchRevisionArchive(revisionId) {
$('#vault-fetch-directory-modal').modal('hide');
const vaultUrl = Urls.api_1_vault_cook_revision_gitfast(revisionId);
fetch(vaultUrl)
.then(response => response.json())
.then(data => {
swh.vault.fetchCookedObject(data.fetch_url);
});
}
diff --git a/swh/web/templates/includes/vault-create-tasks.html b/swh/web/templates/includes/vault-create-tasks.html
index 7d8e5a51..e49e7725 100644
--- a/swh/web/templates/includes/vault-create-tasks.html
+++ b/swh/web/templates/includes/vault-create-tasks.html
@@ -1,197 +1,175 @@
{% comment %}
Copyright (C) 2017-2020 The Software Heritage developers
See the AUTHORS file at the top-level directory of this distribution
License: GNU Affero General Public License version 3, or any later version
See top-level LICENSE file for more information
{% endcomment %}
{% load swh_templatetags %}
{% if vault_cooking %}
{% if user.is_staff %}