From 5b9e1610082492fc8b48ebc726239aee97da75df Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Tue, 31 Aug 2021 17:41:23 +0200 Subject: [PATCH] Changed backend for handling copy and move of multiple selected nodes --- .../ui/controller/NodesController.java | 111 +++++++++++++----- .../java/it/inaf/ia2/vospace/ui/data/Job.java | 3 +- .../vospace/ui/data/MoveOrCopyRequest.java | 39 ++++++ .../ui/service/MainNodesHtmlGenerator.java | 6 + ... => MoveOrCopyNodeModalHtmlGenerator.java} | 15 +-- .../ui/controller/NodesControllerTest.java | 48 ++++---- 6 files changed, 159 insertions(+), 63 deletions(-) create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java rename vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/{MoveNodeModalHtmlGenerator.java => MoveOrCopyNodeModalHtmlGenerator.java} (68%) 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 5560317..5133781 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 @@ -9,16 +9,20 @@ import it.inaf.ia2.aa.data.User; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; import it.inaf.ia2.vospace.ui.data.Job; import it.inaf.ia2.vospace.ui.data.ListNodeData; +import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest; import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator; -import it.inaf.ia2.vospace.ui.service.MoveNodeModalHtmlGenerator; +import it.inaf.ia2.vospace.ui.service.MoveOrCopyNodeModalHtmlGenerator; import it.inaf.oats.vospace.datamodel.NodeUtils; import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; @@ -49,8 +53,8 @@ public class NodesController extends BaseController { @Value("${vospace-authority}") private String authority; - @Value("${maxPollingAttempts:10}") - private int maxPollingAttempts; + @Value("${pollingTimeout:15}") + private int pollingTimeout; @Autowired private VOSpaceClient client; @@ -76,10 +80,10 @@ public class NodesController extends BaseController { return ResponseEntity.ok(listNodeData); } - @GetMapping(value = "/nodesForMove", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity<ListNodeData> listNodesForMoveModal(@RequestParam("path") String path, @RequestParam("nodeToMove") String nodeToMove, User principal) throws Exception { + @GetMapping(value = "/nodesForMoveOrCopy", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity<ListNodeData> listNodesForMoveOrCopyModal(@RequestParam("path") String path, @RequestParam("target") List<String> targetNodes, User principal) throws Exception { - LOG.debug("listNodes called for path {}", path); + LOG.debug("nodesForMoveOrCopy called for path {}", path); ListNodeData listNodeData = new ListNodeData(); @@ -87,7 +91,7 @@ public class NodesController extends BaseController { listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups())); - MoveNodeModalHtmlGenerator htmlGenerator = new MoveNodeModalHtmlGenerator(node, nodeToMove, principal, authority); + MoveOrCopyNodeModalHtmlGenerator htmlGenerator = new MoveOrCopyNodeModalHtmlGenerator(node, targetNodes, principal, authority); listNodeData.setHtml(htmlGenerator.generateNodes()); return ResponseEntity.ok(listNodeData); @@ -203,40 +207,83 @@ public class NodesController extends BaseController { return commonParent; } - @PostMapping(value = "/move") - public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) { + @PostMapping(value = "/moveOrCopy") + public ResponseEntity<List<Job>> moveOrCopyNodes(@RequestBody MoveOrCopyRequest request) throws Exception { - String target = urlEncodePath(getRequiredParam(params, "target")); - String direction = urlEncodePath(getRequiredParam(params, "direction")); + CompletableFuture<JobSummary>[] futureJobs = request.getTargets().stream().map(t -> { + String target = urlEncodePath(t); + String direction = urlEncodePath(request.getDirection()); + Transfer transfer = new Transfer(); + transfer.setTarget("vos://" + authority + target); + transfer.setDirection("vos://" + authority + direction); + transfer.setKeepBytes(request.isKeepBytes()); + return CompletableFuture.supplyAsync(() -> client.startTransferJob(transfer), Runnable::run); + }).collect(Collectors.toList()).toArray(CompletableFuture[]::new); - Transfer transfer = new Transfer(); - transfer.setTarget("vos://" + authority + target); - transfer.setDirection("vos://" + authority + direction); + CompletableFuture.allOf(futureJobs).join(); - JobSummary job = client.startTransferJob(transfer); + List<JobSummary> jobs = Stream.of(futureJobs).map(j -> j.join()).collect(Collectors.toList()); - // Try to perform polling until completion. If it takes too much time - // sends the execution phase to the UI and let it handles the polling. - ExecutionPhase phase; - int i = 0; - do { - phase = client.getJobPhase(job.getJobId()); - if (phase == ExecutionPhase.COMPLETED) { - break; - } else if (phase == ExecutionPhase.ERROR) { - String errorDetail = client.getErrorDetail(job.getJobId()); - throw new VOSpaceException("MoveNode operation failed: " + errorDetail); - } + AtomicReference<Boolean> cancelled = new AtomicReference<>(false); + + CompletableFuture timeout = CompletableFuture.runAsync(() -> { try { - Thread.sleep(1000); + Thread.sleep(pollingTimeout * 1000); } catch (InterruptedException ex) { } - i++; - } while (i < maxPollingAttempts); + cancelled.set(true); + }); + + try { + CompletableFuture.anyOf(jobsPolling(jobs, cancelled), timeout).join(); + } catch (CompletionException ex) { + if (ex.getCause() != null && ex.getCause() instanceof VOSpaceException) { + throw (VOSpaceException) ex.getCause(); + } + throw ex; + } + + // Try to perform polling until completion. If it takes too much time + // sends the execution phase to the UI and let it handles the polling. + Job.JobType type = request.isKeepBytes() ? Job.JobType.COPY : Job.JobType.MOVE; - job.setPhase(phase); + return ResponseEntity.ok(jobs.stream().map(j -> new Job(j, type)) + .collect(Collectors.toList())); + } + + private CompletableFuture<?> jobsPolling(List<JobSummary> jobs, AtomicReference<Boolean> cancelled) { + return CompletableFuture.runAsync(() -> { + + List<JobSummary> uncompletedJobs; + do { + uncompletedJobs = jobs.stream() + .filter(j -> ExecutionPhase.COMPLETED != j.getPhase()) + .collect(Collectors.toList()); + + if (!uncompletedJobs.isEmpty()) { + try { + Thread.sleep(1000); + } catch (InterruptedException ex) { + break; + } + updatePhases(uncompletedJobs); + } + } while (!uncompletedJobs.isEmpty() && !cancelled.get()); + }, Runnable::run); + } + + private void updatePhases(List<JobSummary> uncompletedJobs) { + CompletableFuture[] phaseFutures = uncompletedJobs.stream() + .map(job -> CompletableFuture.runAsync(() -> { + ExecutionPhase phase = client.getJobPhase(job.getJobId()); + if (phase == ExecutionPhase.ERROR) { + String errorDetail = client.getErrorDetail(job.getJobId()); + throw new VOSpaceException("Operation failed: " + errorDetail); + } + job.setPhase(phase); + }, Runnable::run)).collect(Collectors.toList()).toArray(CompletableFuture[]::new); - return ResponseEntity.ok(new Job(job, Job.JobType.MOVE)); + CompletableFuture.allOf(phaseFutures).join(); } 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 06ff9ed..725bb03 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 @@ -16,7 +16,8 @@ public class Job { public static enum JobType { ASYNC_RECALL, ARCHIVE, - MOVE + MOVE, + COPY } private String id; diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java new file mode 100644 index 0000000..4e2b89a --- /dev/null +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java @@ -0,0 +1,39 @@ +/* + * 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.data; + +import java.util.List; + +public class MoveOrCopyRequest { + + private List<String> targets; + private String direction; + private boolean keepBytes; + + public List<String> getTargets() { + return targets; + } + + public void setTargets(List<String> targets) { + this.targets = targets; + } + + public String getDirection() { + return direction; + } + + public void setDirection(String direction) { + this.direction = direction; + } + + public boolean isKeepBytes() { + return keepBytes; + } + + public void setKeepBytes(boolean keepBytes) { + this.keepBytes = keepBytes; + } +} diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java index 5aaef51..0975ca3 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java @@ -137,6 +137,12 @@ public class MainNodesHtmlGenerator extends NodesHtmlGenerator { moveBtn.attr("class", "dropdown-item"); moveBtn.attr("onclick", "moveNode(" + nodePathJs + ")"); + Element copyBtn = dropdown.appendElement("button"); + copyBtn.text("Copy"); + copyBtn.attr("type", "button"); + copyBtn.attr("class", "dropdown-item"); + copyBtn.attr("onclick", "copyNode(" + nodePathJs + ")"); + Element deleteBtn = dropdown.appendElement("button"); deleteBtn.text("Delete"); deleteBtn.attr("type", "button"); diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java similarity index 68% rename from vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java rename to vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java index f83d8cd..74327ae 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java @@ -6,23 +6,24 @@ package it.inaf.ia2.vospace.ui.service; import it.inaf.ia2.aa.data.User; +import java.util.List; import net.ivoa.xml.vospace.v2.Node; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; -public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator { +public class MoveOrCopyNodeModalHtmlGenerator extends NodesHtmlGenerator { - private final String nodeNodeMovePath; + private final List<String> targetNodes; - public MoveNodeModalHtmlGenerator(Node node, String nodeNodeMovePath, User user, String authority) { + public MoveOrCopyNodeModalHtmlGenerator(Node node, List<String> targetNodes, User user, String authority) { super(node, user, authority); - this.nodeNodeMovePath = nodeNodeMovePath; + this.targetNodes = targetNodes; } @Override protected Element createContainerElement(Document html) { Element container = html.body().appendElement("div"); - container.attr("id", "move-nodes"); + container.attr("id", "move-or-copy-nodes"); container.attr("class", "list-group"); return container; } @@ -31,7 +32,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator { protected void addChild(Node child, Element containerElement) { NodeInfo nodeInfo = new NodeInfo(child, user, authority); - if (!nodeInfo.isFolder() || nodeInfo.getPath().equals(nodeNodeMovePath)) { + if (!nodeInfo.isFolder() || targetNodes.contains(nodeInfo.getPath())) { return; } @@ -45,7 +46,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator { private void addLink(NodeInfo nodeInfo, Element cell) { Element link = cell.appendElement("a"); link.attr("href", "#"); - link.attr("onclick", "openNodeInMoveModal(event, " + makeJsArg(nodeInfo.getPath()) + ")"); + link.attr("onclick", "openNodeInMoveOrCopyModal(event, " + makeJsArg(nodeInfo.getPath()) + ")"); link.text(nodeInfo.getName()); } } 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 97d46f2..9835d7d 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 @@ -8,17 +8,16 @@ package it.inaf.ia2.vospace.ui.controller; import com.fasterxml.jackson.databind.ObjectMapper; import it.inaf.ia2.aa.data.User; import it.inaf.ia2.vospace.ui.client.VOSpaceClient; -import it.inaf.ia2.vospace.ui.data.Job; +import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest; import it.inaf.ia2.vospace.ui.exception.VOSpaceException; import java.util.Arrays; import java.util.List; -import java.util.Map; import javax.servlet.http.HttpSession; 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.DataNode; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.hamcrest.CoreMatchers.is; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -41,12 +40,13 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; 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.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.springframework.web.util.NestedServletException; @SpringBootTest @AutoConfigureMockMvc -@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "maxPollingAttempts=2"}) +@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "pollingTimeout=2"}) public class NodesControllerTest { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -56,7 +56,7 @@ public class NodesControllerTest { @Autowired private NodesController nodesController; - + @Autowired private MockMvc mockMvc; @@ -127,7 +127,7 @@ public class NodesControllerTest { } @Test - public void testListNodesForMoveModal() throws Exception { + public void testListNodesForMoveOrCopyModal() throws Exception { ContainerNode parent = new ContainerNode(); parent.setUri("vos://example.com!vospace/a/b/c"); @@ -144,15 +144,23 @@ public class NodesControllerTest { child3.setUri("vos://example.com!vospace/a/b/c/c3"); parent.getNodes().add(child3); + ContainerNode child4 = new ContainerNode(); + child4.setUri("vos://example.com!vospace/a/b/c/c3"); + parent.getNodes().add(child4); + when(client.getNode(any())).thenReturn(parent); - String response = mockMvc.perform(get("/nodesForMove?path=/a/b/c&nodeToMove=/a/b/c/c3")) + String response = mockMvc.perform(get("/nodesForMoveOrCopy") + .param("path", "/a/b/c") + .param("target", "/a/b/c/c3") + .param("target", "/a/b/c/c4")) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); assertTrue(response.contains("c1")); // folder assertFalse(response.contains("c2")); // data node assertFalse(response.contains("c3")); // nodeToMove + assertFalse(response.contains("c4")); // nodeToMove verify(client, times(1)).getNode(eq("/a/b/c")); } @@ -164,13 +172,9 @@ public class NodesControllerTest { .thenReturn(ExecutionPhase.EXECUTING) .thenReturn(ExecutionPhase.COMPLETED); - String response = testMoveNode() + testMoveNode() .andExpect(status().isOk()) - .andReturn().getResponse() - .getContentAsString(); - - Job job = MAPPER.readValue(response, Job.class); - assertEquals(ExecutionPhase.COMPLETED, job.getPhase()); + .andExpect(jsonPath("$[0].phase", is("COMPLETED"))); } @Test @@ -179,13 +183,9 @@ public class NodesControllerTest { when(client.getJobPhase("job_id")) .thenReturn(ExecutionPhase.EXECUTING); - String response = testMoveNode() + testMoveNode() .andExpect(status().isOk()) - .andReturn().getResponse() - .getContentAsString(); - - Job job = MAPPER.readValue(response, Job.class); - assertEquals(ExecutionPhase.EXECUTING, job.getPhase()); + .andExpect(jsonPath("$[0].phase", is("EXECUTING"))); } @Test @@ -220,7 +220,7 @@ public class NodesControllerTest { verify(client, times(1)).createNode(any()); } - + private ResultActions testMoveNode() throws Exception { JobSummary job = new JobSummary(); @@ -228,10 +228,12 @@ public class NodesControllerTest { when(client.startTransferJob(any())).thenReturn(job); - Map<String, String> params = Map.of("target", "/path/to/target", "direction", "/path/to/direction"); + MoveOrCopyRequest request = new MoveOrCopyRequest(); + request.setTargets(List.of("/path/to/target")); + request.setDirection("/path/to/direction"); - return mockMvc.perform(post("/move") + return mockMvc.perform(post("/moveOrCopy") .contentType(MediaType.APPLICATION_JSON) - .content(MAPPER.writeValueAsString(params))); + .content(MAPPER.writeValueAsString(request))); } } -- GitLab