diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java index db0e40b7decd6c2e4976e48e57dca4f222bbf7ed..f251deba7529a664c3d2d05d70526b7884b644fd 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/VOSpaceUiApplication.java @@ -8,6 +8,7 @@ package it.inaf.ia2.vospace.ui; import it.inaf.ia2.aa.AuthConfig; import it.inaf.ia2.aa.LoginFilter; import it.inaf.ia2.aa.ServiceLocator; +import it.inaf.ia2.aa.ServletRapClient; import it.inaf.ia2.aa.UserManager; import it.inaf.ia2.gms.client.GmsClient; import it.inaf.ia2.rap.client.ClientCredentialsRapClient; @@ -86,4 +87,9 @@ public class VOSpaceUiApplication { String gmsBaseUri = ServiceLocator.getInstance().getConfig().getGmsUri(); return new GmsClient(gmsBaseUri); } + + @Bean + public ServletRapClient servletRapClient() { + return (ServletRapClient) ServiceLocator.getInstance().getRapClient(); + } } diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java index c4a3659c492987f2e44d702f561f1fb2ecea9883..d7c0d3cf096b0216c242c745ad2b815a2a5d1215 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java @@ -175,9 +175,20 @@ public class VOSpaceClient { return call(request, BodyHandlers.ofInputStream(), 200, res -> unmarshal(res, Node.class)); } - public List<Job> getJobs() { + public List<Job> getAsyncRecallJobs() { + return getJobs("direction=pullToVoSpace", Job.JobType.ASYNC_RECALL); + } + + public List<Job> getArchiveJobs() { + return getJobs("direction=pullFromVoSpace" + + "&VIEW=ivo://ia2.inaf.it/vospace/views%23tar" + + "&VIEW=ivo://ia2.inaf.it/vospace/views%23zip", + Job.JobType.ARCHIVE); + } - HttpRequest request = getRequest("/transfers?direction=pullToVoSpace") + private List<Job> getJobs(String queryString, Job.JobType type) { + + HttpRequest request = getRequest("/transfers?" + queryString) .header("Accept", useJson ? "application/json" : "text/xml") .header("Content-Type", useJson ? "application/json" : "text/xml") .GET() @@ -185,11 +196,27 @@ public class VOSpaceClient { return call(request, BodyHandlers.ofInputStream(), 200, res -> { return unmarshal(res, Jobs.class).getJobref().stream() - .map(jobDesc -> new Job(jobDesc)) + .map(jobDesc -> new Job(jobDesc, type)) .collect(Collectors.toList()); }); } + public String getArchiveJobHref(String jobId) { + List<Protocol> protocols = getTransferDetails(jobId).getProtocols(); + if (!protocols.isEmpty()) { + return protocols.get(0).getEndpoint(); + } + return null; + } + + private Transfer getTransferDetails(String jobId) { + + HttpRequest request = getRequest("/transfers/" + jobId + "/results/transferDetails") + .GET().build(); + + return call(request, BodyHandlers.ofInputStream(), 200, res -> unmarshal(res, Transfer.class)); + } + public ExecutionPhase getJobPhase(String jobId) { HttpRequest request = getRequest("/transfers/" + jobId + "/phase") diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/DownloadController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/DownloadController.java new file mode 100644 index 0000000000000000000000000000000000000000..71fadfae413fb05b5439a80523e15ee77eb59660 --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/DownloadController.java @@ -0,0 +1,96 @@ +/* + * This file is part of vospace-ui + * Copyright (C) 2021 Istituto Nazionale di Astrofisica + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.inaf.ia2.vospace.ui.controller; + +import it.inaf.ia2.aa.ServletRapClient; +import it.inaf.ia2.aa.data.User; +import it.inaf.ia2.rap.client.call.TokenExchangeRequest; +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.exception.PermissionDeniedException; +import it.inaf.oats.vospace.datamodel.NodeUtils; +import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath; +import java.util.Arrays; +import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.vospace.v2.Protocol; +import net.ivoa.xml.vospace.v2.Transfer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DownloadController { + + private static final Logger LOG = LoggerFactory.getLogger(DownloadController.class); + + @Value("${vospace-authority}") + private String authority; + + @Autowired + private VOSpaceClient client; + + @Autowired + private HttpServletRequest servletRequest; + + @Autowired + private ServletRapClient rapClient; + + @GetMapping(value = "/download/**") + public ResponseEntity<?> directDownload() { + + String path = getPath("/download/"); + LOG.debug("directDownload called for path {}", path); + + Transfer transfer = new Transfer(); + transfer.setDirection("pullFromVoSpace"); + transfer.setTarget(Arrays.asList("vos://" + authority + urlEncodePath(path))); + + Protocol protocol = new Protocol(); + protocol.setUri("ivo://ivoa.net/vospace/core#httpget"); + transfer.getProtocols().add(protocol); + + String url = client.getFileServiceEndpoint(transfer); + HttpHeaders headers = new HttpHeaders(); + headers.set("Location", url); + return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER); + } + + @GetMapping(value = "/download") + public ResponseEntity<?> downloadJobResult(@RequestParam("jobId") String jobId) { + + LOG.debug("job result download called for jobId {}", jobId); + + String token = ((User) servletRequest.getUserPrincipal()).getAccessToken(); + + if (token == null) { + throw new PermissionDeniedException("Token is null"); + } + + String endpoint = client.getArchiveJobHref(jobId); + + TokenExchangeRequest exchangeRequest = new TokenExchangeRequest() + .setSubjectToken(token) + .setResource(endpoint); + + String newToken = rapClient.exchangeToken(exchangeRequest, servletRequest); + + String url = endpoint + "?token=" + newToken; + HttpHeaders headers = new HttpHeaders(); + headers.set("Location", url); + return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER); + } + + protected String getPath(String prefix) { + String requestURL = servletRequest.getRequestURL().toString(); + return NodeUtils.getPathFromRequestURLString(requestURL, prefix); + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/JobController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/JobController.java index 3c560e5676f3079d392b885a80f267f1e7cbbe9c..352507fce507266a78c4d82a392210ee3b43fd45 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/JobController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/JobController.java @@ -8,7 +8,9 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; import it.inaf.ia2.vospace.ui.data.Job; import it.inaf.ia2.vospace.ui.exception.BadRequestException; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import net.ivoa.xml.uws.v1.ErrorType; import net.ivoa.xml.uws.v1.ExecutionPhase; @@ -30,7 +32,7 @@ import org.springframework.web.bind.annotation.RestController; public class JobController extends BaseController { private static final Logger LOG = LoggerFactory.getLogger(JobController.class); - + @Value("${vospace-authority}") private String authority; @@ -55,11 +57,11 @@ public class JobController extends BaseController { transfer.getProtocols().add(protocol); JobSummary job = client.startTransferJob(transfer); - + if (job.getPhase() == ExecutionPhase.QUEUED || job.getPhase() == ExecutionPhase.PENDING || job.getPhase() == ExecutionPhase.EXECUTING) { - return ResponseEntity.ok(new Job(job)); + return ResponseEntity.ok(new Job(job, Job.JobType.ASYNC_RECALL)); } String errorMessage; if (job.getPhase() == ExecutionPhase.ERROR) { @@ -87,7 +89,22 @@ public class JobController extends BaseController { } @GetMapping(value = "/jobs", produces = MediaType.APPLICATION_JSON_VALUE) - public List<Job> getJobs() { - return client.getJobs(); + public List<Job> getJobs() throws Exception { + + CompletableFuture<List<Job>> asyncRecallJobsCall + = CompletableFuture.supplyAsync(() -> client.getAsyncRecallJobs(), + Runnable::run); + + CompletableFuture<List<Job>> archiveJobsCall + = CompletableFuture.supplyAsync(() -> client.getArchiveJobs(), + Runnable::run); + + CompletableFuture.allOf(asyncRecallJobsCall, archiveJobsCall).join(); + + List<Job> jobs = new ArrayList<>(); + jobs.addAll(asyncRecallJobsCall.get()); + jobs.addAll(archiveJobsCall.get()); + + return jobs; } } diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java index 5413860675d77eeceb1690e72448c0f0f51b8637..bdcda111a17fce30c84fb4f6567cc0e8c701af68 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java @@ -25,15 +25,15 @@ import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Node; +import net.ivoa.xml.vospace.v2.Param; import net.ivoa.xml.vospace.v2.Property; import net.ivoa.xml.vospace.v2.Protocol; import net.ivoa.xml.vospace.v2.Transfer; +import net.ivoa.xml.vospace.v2.View; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -52,7 +52,7 @@ public class NodesController extends BaseController { @Value("${maxPollingAttempts:10}") private int maxPollingAttempts; - + @Autowired private VOSpaceClient client; @@ -81,7 +81,7 @@ public class NodesController extends BaseController { public ResponseEntity<ListNodeData> listNodesForMoveModal(@RequestParam("path") String path, @RequestParam("nodeToMove") String nodeToMove, User principal) throws Exception { LOG.debug("listNodes called for path {}", path); - + ListNodeData listNodeData = new ListNodeData(); Node node = client.getNode(path); @@ -94,26 +94,6 @@ public class NodesController extends BaseController { return ResponseEntity.ok(listNodeData); } - @GetMapping(value = "/download/**") - public ResponseEntity<?> directDownload() { - - String path = getPath("/download/"); - LOG.debug("directDownload called for path {}", path); - - Transfer transfer = new Transfer(); - transfer.setDirection("pullFromVoSpace"); - transfer.setTarget(Arrays.asList("vos://" + authority + urlEncodePath(path))); - - Protocol protocol = new Protocol(); - protocol.setUri("ivo://ivoa.net/vospace/core#httpget"); - transfer.getProtocols().add(protocol); - - String url = client.getFileServiceEndpoint(transfer); - HttpHeaders headers = new HttpHeaders(); - headers.set("Location", url); - return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER); - } - @PostMapping(value = "/folder") public void newFolder(@RequestBody Map<String, Object> params) { @@ -162,6 +142,68 @@ public class NodesController extends BaseController { return ResponseEntity.noContent().build(); } + @PostMapping(value = "/zip", consumes = MediaType.APPLICATION_JSON_VALUE) + public Job createZip(@RequestBody List<String> paths) { + return createArchive(paths, "ivo://ia2.inaf.it/vospace/views#zip"); + } + + @PostMapping(value = "/tar", consumes = MediaType.APPLICATION_JSON_VALUE) + public Job createTar(@RequestBody List<String> paths) { + return createArchive(paths, "ivo://ia2.inaf.it/vospace/views#tar"); + } + + private Job createArchive(List<String> paths, String viewUri) { + + Transfer transfer = new Transfer(); + + View view = new View(); + view.setUri(viewUri); + transfer.setView(view); + + if (paths.size() == 1) { + transfer.setTarget(Arrays.asList("vos://" + authority + paths.get(0))); + } else { + String parent = getCommonParent(paths); + transfer.setTarget(Arrays.asList("vos://" + authority + parent)); + for (String path : paths) { + String childName = path.substring(parent.length() + 1); + Param param = new Param(); + param.setUri(viewUri + "/include"); + param.setValue(childName); + view.getParam().add(param); + } + } + + transfer.setDirection("pullFromVoSpace"); + Protocol protocol = new Protocol(); + protocol.setUri("ivo://ivoa.net/vospace/core#httpget"); + transfer.getProtocols().add(protocol); + + return new Job(client.startTransferJob(transfer), Job.JobType.ARCHIVE); + } + + private String getCommonParent(List<String> vosPaths) { + String commonParent = null; + for (String vosPath : vosPaths) { + if (commonParent == null) { + commonParent = vosPath; + } else { + StringBuilder newCommonParent = new StringBuilder(); + boolean same = true; + int lastSlashPos = vosPath.lastIndexOf("/"); + for (int i = 0; same && i < Math.min(commonParent.length(), vosPath.length()) && i < lastSlashPos; i++) { + if (commonParent.charAt(i) == vosPath.charAt(i)) { + newCommonParent.append(commonParent.charAt(i)); + } else { + same = false; + } + } + commonParent = newCommonParent.toString(); + } + } + return commonParent; + } + @PostMapping(value = "/move") public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) { @@ -195,7 +237,7 @@ public class NodesController extends BaseController { job.setPhase(phase); - return ResponseEntity.ok(new Job(job)); + return ResponseEntity.ok(new Job(job, Job.JobType.MOVE)); } protected String getPath(String prefix) { diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java index 6bd4ec4ad44e91c441b78e05301a840a056d8d1a..06ff9ed8b6d134528f91e5f0bdbc8ee0c1440271 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java @@ -13,24 +13,33 @@ import net.ivoa.xml.uws.v1.ShortJobDescription; public class Job { + public static enum JobType { + ASYNC_RECALL, + ARCHIVE, + MOVE + } + private String id; private String creationTime; private ExecutionPhase phase; private boolean read; + private JobType type; public Job() { } - public Job(JobSummary job) { + public Job(JobSummary job, JobType type) { this.id = job.getJobId(); this.creationTime = formatCreationTime(job.getCreationTime()); this.phase = job.getPhase(); + this.type = type; } - public Job(ShortJobDescription job) { + public Job(ShortJobDescription job, JobType type) { this.id = job.getId(); this.creationTime = formatCreationTime(job.getCreationTime()); this.phase = job.getPhase(); + this.type = type; } private String formatCreationTime(XMLGregorianCalendar calendar) { @@ -72,4 +81,12 @@ public class Job { public void setRead(boolean read) { this.read = read; } + + public JobType getType() { + return type; + } + + public void setType(JobType type) { + this.type = type; + } } diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/DownloadControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/DownloadControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..51cd0e737fe7a200af29ca03aa2104ea387fd7fb --- /dev/null +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/DownloadControllerTest.java @@ -0,0 +1,69 @@ +/* + * This file is part of vospace-ui + * Copyright (C) 2021 Istituto Nazionale di Astrofisica + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.inaf.ia2.vospace.ui.controller; + +import it.inaf.ia2.aa.ServletRapClient; +import it.inaf.ia2.aa.data.User; +import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@TestPropertySource(properties = {"vospace-authority=example.com!vospace"}) +public class DownloadControllerTest { + + @MockBean + private VOSpaceClient client; + + @MockBean + private ServletRapClient rapClient; + + @Autowired + private MockMvc mockMvc; + + @Test + public void testDirectDownload() throws Exception { + + when(client.getFileServiceEndpoint(any())).thenReturn("http://redirect"); + + mockMvc.perform(get("/download/myfile")) + .andExpect(status().is3xxRedirection()); + + verify(client, times(1)).getFileServiceEndpoint(any()); + } + + @Test + public void testDownloadJobResult() throws Exception { + + User user = mock(User.class); + when(user.getAccessToken()).thenReturn("<token>"); + + when(client.getArchiveJobHref("job123")).thenReturn("http://file-service/job123.zip"); + + when(rapClient.exchangeToken(any(), any())).thenReturn("<new-token>"); + + String redirect = mockMvc.perform(get("/download?jobId=job123") + .principal(user)) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse().getRedirectedUrl(); + + assertEquals("http://file-service/job123.zip?token=<new-token>", redirect); + } +} diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/JobControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/JobControllerTest.java index 4b6f6754b12efd903b100ff6393cb1f7e5e44132..b4209c93a626f42d65417a12de46b4956c4cf972 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/JobControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/JobControllerTest.java @@ -6,6 +6,8 @@ package it.inaf.ia2.vospace.ui.controller; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; +import it.inaf.ia2.vospace.ui.data.Job; +import java.util.List; import java.util.function.Consumer; import net.ivoa.xml.uws.v1.ErrorSummary; import net.ivoa.xml.uws.v1.ErrorType; @@ -24,8 +26,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import org.springframework.web.client.RestTemplate; @SpringBootTest @@ -121,6 +125,19 @@ public class JobControllerTest { testErrorCall(ex -> assertTrue(ex.getMessage().contains("error"))); } + @Test + public void testGetJobs() throws Exception { + + when(client.getAsyncRecallJobs()).thenReturn(List.of(new Job(new JobSummary(), Job.JobType.ASYNC_RECALL))); + when(client.getArchiveJobs()).thenReturn(List.of(new Job(new JobSummary(), Job.JobType.ARCHIVE))); + + mockMvc.perform(get("/jobs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].type").value("ASYNC_RECALL")) + .andExpect(jsonPath("$[1].type").value("ARCHIVE")); + } + private void testErrorCall(Consumer<Exception> exceptionChecker) throws Exception { try { mockMvc.perform(post("/recall") diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java index 013590ded3a774ae6ade34d97ff1db56f3e495b8..97d46f24d0e5533f52480e3cb3bda5588d8d9600 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java @@ -205,17 +205,6 @@ public class NodesControllerTest { } } - @Test - public void testDirectDownload() throws Exception { - - when(client.getFileServiceEndpoint(any())).thenReturn("http://redirect"); - - mockMvc.perform(get("/download/myfile")) - .andExpect(status().is3xxRedirection()); - - verify(client, times(1)).getFileServiceEndpoint(any()); - } - @Test public void testNewFolder() throws Exception { diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js index ee4b9942f7573c25b70858c535352d12bb41957a..908567f36e59b156f2cc6f66216cbd0d326c1308 100644 --- a/vospace-ui-frontend/src/api/server/index.js +++ b/vospace-ui-frontend/src/api/server/index.js @@ -203,5 +203,17 @@ export default { }, data }, true, true); + }, + createArchive(paths, type) { + let url = BASE_API_URL + type; + return apiRequest({ + method: 'POST', + url: url, + withCredentials: true, + headers: { + 'Cache-Control': 'no-cache' + }, + data: paths + }, true, true); } } diff --git a/vospace-ui-frontend/src/components/Jobs.vue b/vospace-ui-frontend/src/components/Jobs.vue index 3c54d8aea9b8a9286baa9964c2fd9d08424e60f7..c77089cb97f499dfe63929cf608114fc4112470d 100644 --- a/vospace-ui-frontend/src/components/Jobs.vue +++ b/vospace-ui-frontend/src/components/Jobs.vue @@ -6,23 +6,32 @@ <template> <div> <b-button variant="primary" class="float-right" @click="loadJobs">Reload</b-button> - <h3>Async recall jobs</h3> - <table class="table b-table table-striped table-hover"> - <thead> - <tr> - <th>Creation time</th> - <th>Id</th> - <th>Phase</th> - </tr> - </thead> - <tbody> - <tr v-for="job in jobs" :key="job.id"> - <td>{{job.creationTime}}</td> - <td>{{job.id}}</td> - <td>{{job.phase}}</td> - </tr> - </tbody> - </table> + <h3>Jobs</h3> + <div v-if="jobs.length > 0" class="mb-3"> + <table class="table b-table table-striped table-hover"> + <thead> + <tr> + <th>Type</th> + <th>Creation time</th> + <th>Id</th> + <th>Link</th> + <th>Phase</th> + </tr> + </thead> + <tbody> + <tr v-for="job in jobs" :key="job.id"> + <td>{{job.type}}</td> + <td>{{job.creationTime}}</td> + <td>{{job.id}}</td> + <td><a :href="'download?jobId=' + job.id" v-if="job.phase === 'COMPLETED' && job.type === 'ARCHIVE'">Download archive</a></td> + <td>{{job.phase}}</td> + </tr> + </tbody> + </table> + </div> + <div v-if="jobs.length === 0"> + No jobs + </div> <div id="jobs-loading" v-if="jobsLoading" class="loading"> <div class="spinner-wrapper"> <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner> diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue index dfc5f2c1ccbe64ee47ae432b7a31aad350254bd9..bf2ff6122205165ac95eaca0b15b159af091855b 100644 --- a/vospace-ui-frontend/src/components/Main.vue +++ b/vospace-ui-frontend/src/components/Main.vue @@ -9,8 +9,12 @@ <div class="mb-3"> <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-folder-modal>New folder</b-button> <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.upload-files-modal>Upload files</b-button> - <b-button variant="primary" class="mr-2" v-if="asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-button> - <b-button variant="danger" class="mr-2" v-if="deleteButtonEnabled" @click="deleteNodes">Delete</b-button> + <b-dropdown variant="primary" text="Actions" v-if="asyncButtonEnabled || deleteButtonEnabled || archiveButtonEnabled"> + <b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item> + <b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item> + <b-dropdown-item :disabled="!archiveButtonEnabled" @click="createArchive('zip')">Create zip archive</b-dropdown-item> + <b-dropdown-item :disabled="!archiveButtonEnabled" @click="createArchive('tar')">Create tar archive</b-dropdown-item> + </b-dropdown> </div> <b-card> <table class="table b-table table-striped table-hover"> @@ -41,6 +45,7 @@ <ShareModal /> <RenameModal /> <MoveModal /> + <ConfirmArchiveModal /> </div> </template> @@ -52,6 +57,7 @@ import ConfirmDeleteModal from './modal/ConfirmDeleteModal.vue' import ShareModal from './modal/ShareModal.vue' import RenameModal from './modal/RenameModal.vue' import MoveModal from './modal/MoveModal.vue' +import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue' export default { components: { @@ -62,7 +68,8 @@ export default { ConfirmDeleteModal, ShareModal, RenameModal, - MoveModal + MoveModal, + ConfirmArchiveModal }, computed: { breadcrumbs() { @@ -87,6 +94,9 @@ export default { deleteButtonEnabled() { return this.$store.state.deleteButtonEnabled; }, + archiveButtonEnabled() { + return this.$store.state.archiveButtonEnabled; + }, writable() { return this.$store.state.writable; } @@ -118,11 +128,12 @@ export default { }, deleteNodes() { let selectedNodesCheckboxes = document.querySelectorAll('#nodes input:checked'); - let paths = [], unDeletablePaths = []; + let paths = []; + let unDeletablePaths = []; for (let i = 0; i < selectedNodesCheckboxes.length; i++) { let checkbox = selectedNodesCheckboxes[i]; let dataNode = checkbox.getAttribute('data-node'); - if(checkbox.classList.contains('deletable')) { + if (checkbox.classList.contains('deletable')) { paths.push(dataNode); } else { unDeletablePaths.push(dataNode); @@ -131,6 +142,28 @@ export default { this.$store.commit('setNodesToDelete', paths); this.$store.commit('setSelectedUndeletableNodes', unDeletablePaths); this.$bvModal.show('confirm-delete-modal'); + }, + createArchive(type) { + let selectedNodesCheckboxes = document.querySelectorAll('#nodes input:checked'); + let nodesToArchive = []; + let notArchivableNodes = []; + for (let i = 0; i < selectedNodesCheckboxes.length; i++) { + let checkbox = selectedNodesCheckboxes[i]; + let dataNode = checkbox.getAttribute('data-node'); + if (checkbox.classList.contains('async')) { + notArchivableNodes.push(dataNode); + } else { + nodesToArchive.push(dataNode); + } + } + this.$store.commit('setArchiveType', type); + this.$store.commit('setNodesToArchives', nodesToArchive); + if (notArchivableNodes.length === 0) { + this.$store.dispatch('createArchive', type) + } else { + this.$store.commit('setSelectedNotArchivableNodes', notArchivableNodes); + this.$bvModal.show('confirm-archive-modal'); + } } } } diff --git a/vospace-ui-frontend/src/components/modal/ConfirmArchiveModal.vue b/vospace-ui-frontend/src/components/modal/ConfirmArchiveModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..0329cf0d38bd1987f946861082e8c492d3d55c5d --- /dev/null +++ b/vospace-ui-frontend/src/components/modal/ConfirmArchiveModal.vue @@ -0,0 +1,33 @@ +<!-- + This file is part of vospace-ui + Copyright (C) 2021 Istituto Nazionale di Astrofisica + SPDX-License-Identifier: GPL-3.0-or-later +--> +<template> +<b-modal id="confirm-archive-modal" title="Archive creation warning" okTitle="Ok, proceed anyway" @ok.prevent="createArchive" size="lg"> + <p><strong>Warning</strong>: some of the nodes you selected require to be asynchronously retrieved before including them into an archive:</p> + <p> + <ul> + <li v-for="node in notArchivableNodes" :key="node">{{node}}</li> + </ul> + </p> + <p>If you proceed these files will be ignored and not included in the resulting archive file.</p> +</b-modal> +</template> + +<script> +export default { + name: 'ConfirmArchiveModal', + computed: { + notArchivableNodes() { return this.$store.state.selectedNotArchivableNodes } + }, + methods: { + createArchive() { + this.$store.dispatch('createArchive') + .then(() => { + this.$bvModal.hide('confirm-archive-modal'); + }); + } + } +} +</script> diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js index f610813da80b1d635279c78532cd69ce54749b9d..236a773286a3cbc9db2c1710e0849986d5148f3e 100644 --- a/vospace-ui-frontend/src/store.js +++ b/vospace-ui-frontend/src/store.js @@ -28,6 +28,7 @@ export default new Vuex.Store({ nodesLoading: false, asyncButtonEnabled: false, deleteButtonEnabled: false, + archiveButtonEnabled: false, jobs: [], jobsLoading: true, lastJobsCheckTime: null, @@ -43,7 +44,10 @@ export default new Vuex.Store({ nodeToRename: null, nodeToMove: null, nodeToMoveDestination: null, - nodeToMoveDestinationWritable: false + nodeToMoveDestinationWritable: false, + archiveType: null, + nodesToArchive: [], + selectedNotArchivableNodes: [] }, mutations: { setLoading(state, loading) { @@ -64,6 +68,9 @@ export default new Vuex.Store({ setDeleteButtonEnabled(state, value) { state.deleteButtonEnabled = value; }, + setArchiveButtonEnabled(state, value) { + state.archiveButtonEnabled = value; + }, setJobs(state, jobs) { updateArray(state.jobs, jobs); }, @@ -101,6 +108,15 @@ export default new Vuex.Store({ }, setNodeToMoveDestinationWritable(state, value) { state.nodeToMoveDestinationWritable = value; + }, + setArchiveType(state, type) { + state.archiveType = type; + }, + setNodesToArchives(state, paths) { + state.nodesToArchive = paths; + }, + setSelectedNotArchivableNodes(state, paths) { + state.selectedNotArchivableNodes = paths; } }, actions: { @@ -127,6 +143,7 @@ export default new Vuex.Store({ computeButtonsVisibility({ commit }) { commit('setAsyncButtonEnabled', document.querySelectorAll('#nodes input.async:checked').length > 0); commit('setDeleteButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0); + commit('setArchiveButtonEnabled', document.querySelectorAll('#nodes input:not(.async):checked').length > 0); }, startAsyncRecallJob({ state, commit, dispatch }) { let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked'); @@ -239,6 +256,17 @@ export default new Vuex.Store({ commit('setNodeToMoveDestinationWritable', res.writable); document.getElementById('move-nodes').outerHTML = res.html; }); + }, + createArchive({ state, commit }) { + client.createArchive(state.nodesToArchive, state.archiveType) + .then(job => { + if (job.phase === 'ERROR') { + main.showError('Error creating ' + state.archiveType + ' archive'); + } else { + main.showInfo(state.archiveType + ' creation started'); + commit('addJob', job); + } + }); } } });