From 3d9fe230da69d732357626b10c7853fd2bb122a9 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 3 May 2021 16:15:27 +0200
Subject: [PATCH] Automatic reload of node states (#3827); bugfix of missing
 size after upload and other minor issues

---
 .../inaf/ia2/vospace/ui/service/NodeInfo.java |  4 +-
 vospace-ui-frontend/src/App.vue               | 10 ++-
 vospace-ui-frontend/src/api/server/index.js   |  8 +-
 vospace-ui-frontend/src/components/Jobs.vue   |  1 +
 vospace-ui-frontend/src/nodesReloader.js      | 49 ++++++++++++
 vospace-ui-frontend/src/store.js              | 77 ++++++++++++++-----
 6 files changed, 124 insertions(+), 25 deletions(-)
 create mode 100644 vospace-ui-frontend/src/nodesReloader.js

diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
index 6025e00..a439061 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
@@ -48,8 +48,8 @@ public class NodeInfo {
         this.sticky = isSticky(node);
         this.busy = isBusy(node);
         this.listOfFiles = isListOfFiles(node);
-        this.writable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups());
-        this.deletable = writable && !sticky;
+        this.writable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !busy;
+        this.deletable = writable && !sticky && !asyncTrans;
     }
 
     private String getPath(Node node) {
diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue
index 4844d3e..c2db686 100644
--- a/vospace-ui-frontend/src/App.vue
+++ b/vospace-ui-frontend/src/App.vue
@@ -21,6 +21,7 @@
 import { mapState } from 'vuex'
 import TopMenu from './components/TopMenu.vue'
 import client from 'api-client'
+import nodesReloader from './nodesReloader.js'
 
 export default {
   name: 'App',
@@ -34,6 +35,13 @@ export default {
     this.$store.dispatch('loadJobs');
     this.$store.dispatch('loadUserInfo');
     setInterval(client.keepalive, 60000);
+    setInterval(nodesReloader.checkNodes, 1000);
+    let self = this;
+    setInterval(function() {
+      if (self.$router.currentRoute.path !== '/jobs') {
+        self.$store.dispatch('checkJobs');
+      }
+    }, 1000);
   }
 }
 </script>
@@ -95,7 +103,7 @@ export default {
   color: #3293f2;
 }
 
-.node-busy + .icon {
+.node-busy+.icon {
   margin-right: 3px;
 }
 </style>
diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index 48974f1..e22598a 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -46,7 +46,7 @@ function escapePath(path) {
 }
 
 export default {
-  getNode(path) {
+  getNode(path, loading) {
     let url = BASE_API_URL + 'nodes/' + escapePath(path);
     return apiRequest({
       method: 'GET',
@@ -55,7 +55,7 @@ export default {
       headers: {
         'Cache-Control': 'no-cache'
       }
-    }, true, true);
+    }, (typeof loading !== 'undefined') ? loading : true, true);
   },
   loadJobs() {
     let url = BASE_API_URL + 'jobs';
@@ -124,11 +124,11 @@ export default {
   uploadFile(url, file) {
     let formData = new FormData();
     formData.append('file', file);
-    axios.put(url, formData, {
+    return axios.put(url, formData, {
       headers: {
         'Content-Type': 'multipart/form-data'
       }
-    })
+    });
   },
   deleteNodes(paths) {
     let url = BASE_API_URL + 'delete';
diff --git a/vospace-ui-frontend/src/components/Jobs.vue b/vospace-ui-frontend/src/components/Jobs.vue
index c117dc4..bfb1ffb 100644
--- a/vospace-ui-frontend/src/components/Jobs.vue
+++ b/vospace-ui-frontend/src/components/Jobs.vue
@@ -1,5 +1,6 @@
 <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>
diff --git a/vospace-ui-frontend/src/nodesReloader.js b/vospace-ui-frontend/src/nodesReloader.js
new file mode 100644
index 0000000..b730a68
--- /dev/null
+++ b/vospace-ui-frontend/src/nodesReloader.js
@@ -0,0 +1,49 @@
+import store from './store.js'
+import client from 'api-client'
+
+let lastResultTime = null;
+let times = 0;
+
+function checkNodes() {
+  let busyNodes = document.getElementsByClassName('node-busy').length > 0;
+  let jobsInProgress = store.state.jobs.filter(j => j.phase === 'EXECUTING').length > 0;
+  if (!busyNodes && !jobsInProgress) {
+    // reset state
+    lastResultTime = null;
+    times = 0;
+    return;
+  }
+  if (lastResultTime !== null) {
+    // first 10 times check every second, then check every ten seconds
+    let offset = times < 10 ? 1000 : 10000;
+    let now = new Date().getTime();
+    if (now - lastResultTime < offset) {
+      return;
+    }
+  }
+
+  if (!store.state.nodesLoading) {
+    let path = store.state.path;
+    store.commit('setNodesLoading', true);
+    client.getNode(path, false)
+      .then(res => {
+        // check that path didn't change in meantime by user action
+        if (path === store.state.path) {
+          let resHasBusyNodes = res.htmlTable.includes('node-busy');
+          if ((!busyNodes && resHasBusyNodes) || (busyNodes && !resHasBusyNodes)) {
+            store.dispatch('setNodes', res);
+          } else {
+            times++;
+            lastResultTime = new Date().getTime();
+          }
+        }
+      })
+      .finally(() => {
+        store.commit('setNodesLoading', false);
+      });
+  }
+}
+
+export default {
+  checkNodes
+}
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index 13f4791..bf51a33 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -20,10 +20,12 @@ export default new Vuex.Store({
   state: {
     path: '',
     loading: true,
+    nodesLoading: false,
     asyncButtonEnabled: false,
     deleteButtonEnabled: false,
     jobs: [],
     jobsLoading: true,
+    lastJobsCheckTime: null,
     user: 'anonymous',
     nodesToDelete: [],
     writable: false,
@@ -43,6 +45,9 @@ export default new Vuex.Store({
       }
       state.path = value;
     },
+    setNodesLoading(state, value) {
+      state.nodesLoading = value;
+    },
     setAsyncButtonEnabled(state, value) {
       state.asyncButtonEnabled = value;
     },
@@ -76,24 +81,29 @@ export default new Vuex.Store({
   actions: {
     setPath({ state, commit, dispatch }, path) {
       commit('setPath', path);
+      commit('setNodesLoading', true);
       client.getNode(state.path)
         .then(res => {
-          commit('setWritable', res.writable);
-          document.getElementById('nodes').outerHTML = res.htmlTable;
-          let checkboxes = document.querySelectorAll('#nodes input[type="checkbox"]');
-          for (let i = 0; i < checkboxes.length; i++) {
-            checkboxes[i].addEventListener('change', function() {
-              dispatch('computeButtonsVisibility');
-            });
-          }
+          dispatch('setNodes', res);
+        })
+        .finally(() => commit('setNodesLoading', false));
+    },
+    setNodes({ commit, dispatch }, res) {
+      commit('setWritable', res.writable);
+      document.getElementById('nodes').outerHTML = res.htmlTable;
+      let checkboxes = document.querySelectorAll('#nodes input[type="checkbox"]');
+      for (let i = 0; i < checkboxes.length; i++) {
+        checkboxes[i].addEventListener('change', function() {
           dispatch('computeButtonsVisibility');
         });
+      }
+      dispatch('computeButtonsVisibility');
     },
     computeButtonsVisibility({ commit }) {
       commit('setAsyncButtonEnabled', document.querySelectorAll('#nodes input.async:checked').length > 0);
       commit('setDeleteButtonEnabled', document.querySelectorAll('#nodes input.deletable:checked').length > 0);
     },
-    startAsyncRecallJob({ commit }) {
+    startAsyncRecallJob({ state, commit, dispatch }) {
       let asyncCheckboxes = document.querySelectorAll('#nodes input.async:checked');
       let paths = [];
       for (let i = 0; i < asyncCheckboxes.length; i++) {
@@ -101,15 +111,45 @@ export default new Vuex.Store({
       }
       client.startAsyncRecallJob(paths)
         .then(job => {
-          main.showInfo('Job started');
+          main.showInfo('Job queued');
           commit('addJob', job);
+          // Reload current node
+          dispatch('setPath', state.path);
         });
     },
-    loadJobs({ commit }) {
+    checkJobs({ state, dispatch }) {
+      if (state.jobs.filter(j => j.phase === 'QUEUED' || j.phase === 'PENDING' || j.phase === 'EXECUTING').length > 0 &&
+        !state.jobsLoading && (state.lastJobsCheckTime !== null && new Date().getTime() - state.lastJobsCheckTime > 5000)) {
+        dispatch('loadJobs');
+      }
+    },
+    loadJobs({ state, commit }) {
       commit('setJobsLoading', true);
       client.loadJobs()
-        .then(jobs => commit('setJobs', jobs))
-        .finally(() => commit('setJobsLoading', false));
+        .then(jobs => {
+          for (let previousJob of state.jobs) {
+            for (let newJob of jobs) {
+              if (newJob.id === previousJob.id && newJob.phase !== previousJob.phase) {
+                switch (newJob.phase) {
+                  case 'EXECUTING':
+                    main.showInfo('Job started');
+                    break;
+                  case 'COMPLETED':
+                    main.showInfo('Job completed');
+                    break;
+                  case 'ERROR':
+                    main.showError('Job failed');
+                    break;
+                }
+              }
+            }
+          }
+          commit('setJobs', jobs);
+        })
+        .finally(() => {
+          commit('setJobsLoading', false);
+          state.lastJobsCheckTime = new Date().getTime();
+        });
     },
     loadUserInfo({ commit }) {
       client.getUserInfo()
@@ -129,13 +169,14 @@ export default new Vuex.Store({
       }
       client.prepareForUpload(state.path, names)
         .then(uploadUrls => {
+          let uploads = [];
           for (let i = 0; i < files.length; i++) {
-            client.uploadFile(uploadUrls[i], files[i]);
+            uploads.push(client.uploadFile(uploadUrls[i], files[i]));
           }
-        })
-        .then(() => {
-          // Reload current node
-          dispatch('setPath', state.path);
+          Promise.all(uploads).then(() => {
+            // Reload current node when all uploads completed
+            dispatch('setPath', state.path);
+          });
         });
     },
     deleteNodes({ state, dispatch }) {
-- 
GitLab