Repository: kestra-io/kestra
Issue: Display file size on the ui
Labels: backend

When we display a link in order to download a file, it will be nice to have the filesize

Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/core/src/main/java/org/kestra/core/models/storage/FileMetas.java b/core/src/main/java/org/kestra/core/models/storage/FileMetas.java
new file mode 100644
index 0000000000..a6b2b08758
--- /dev/null
+++ b/core/src/main/java/org/kestra/core/models/storage/FileMetas.java
@@ -0,0 +1,13 @@
+package org.kestra.core.models.storage;
+
+import lombok.Builder;
+import lombok.Value;
+
+import javax.validation.constraints.NotNull;
+
+@Value
+@Builder
+public class FileMetas {
+    @NotNull
+    long size;
+}
diff --git a/core/src/main/java/org/kestra/core/storages/StorageInterface.java b/core/src/main/java/org/kestra/core/storages/StorageInterface.java
index 16de6bbd67..8d09914fdf 100644
--- a/core/src/main/java/org/kestra/core/storages/StorageInterface.java
+++ b/core/src/main/java/org/kestra/core/storages/StorageInterface.java
@@ -17,6 +17,8 @@
 public interface StorageInterface {
     InputStream get(URI uri) throws FileNotFoundException;
 
+    Long size(URI uri) throws IOException;
+
     URI put(URI uri, InputStream data) throws IOException;
 
     boolean delete(URI uri) throws IOException;
diff --git a/storage-local/src/main/java/org/kestra/storage/local/LocalStorage.java b/storage-local/src/main/java/org/kestra/storage/local/LocalStorage.java
index b2ed3bd174..212a5ae087 100644
--- a/storage-local/src/main/java/org/kestra/storage/local/LocalStorage.java
+++ b/storage-local/src/main/java/org/kestra/storage/local/LocalStorage.java
@@ -4,6 +4,7 @@
 
 import java.io.*;
 import java.net.URI;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import javax.inject.Inject;
@@ -44,10 +45,15 @@ private void createDirectory(URI append) {
 
     @Override
     public InputStream get(URI uri) throws FileNotFoundException {
-        return new BufferedInputStream(new FileInputStream(new File(getPath(URI.create(uri.getPath()))
+        return new BufferedInputStream(new FileInputStream(getPath(URI.create(uri.getPath()))
             .toAbsolutePath()
-            .toString()
-        )));
+            .toString())
+        );
+    }
+
+    @Override
+    public Long size(URI uri) throws IOException {
+        return Files.size(getPath(URI.create(uri.getPath())));
     }
 
     @Override
diff --git a/ui/package-lock.json b/ui/package-lock.json
index aebe35c6af..b1349c8a97 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -3986,16 +3986,6 @@
           "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
           "dev": true
         },
-        "ansi-styles": {
-          "version": "4.3.0",
-          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "color-convert": "^2.0.1"
-          }
-        },
         "cacache": {
           "version": "13.0.1",
           "resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz",
@@ -4022,53 +4012,6 @@
             "unique-filename": "^1.1.1"
           }
         },
-        "chalk": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
-          "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "ansi-styles": "^4.1.0",
-            "supports-color": "^7.1.0"
-          }
-        },
-        "color-convert": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "color-name": "~1.1.4"
-          }
-        },
-        "color-name": {
-          "version": "1.1.4",
-          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-          "dev": true,
-          "optional": true
-        },
-        "has-flag": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-          "dev": true,
-          "optional": true
-        },
-        "loader-utils": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
-          "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "big.js": "^5.2.2",
-            "emojis-list": "^3.0.0",
-            "json5": "^2.1.2"
-          }
-        },
         "source-map": {
           "version": "0.6.1",
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -4085,16 +4028,6 @@
             "minipass": "^3.1.1"
           }
         },
-        "supports-color": {
-          "version": "7.2.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "has-flag": "^4.0.0"
-          }
-        },
         "terser-webpack-plugin": {
           "version": "2.3.8",
           "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
@@ -4111,18 +4044,6 @@
             "terser": "^4.6.12",
             "webpack-sources": "^1.4.3"
           }
-        },
-        "vue-loader-v16": {
-          "version": "npm:vue-loader@16.1.2",
-          "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.2.tgz",
-          "integrity": "sha512-8QTxh+Fd+HB6fiL52iEVLKqE9N1JSlMXLR92Ijm6g8PZrwIxckgpqjPDWRP5TWxdiPaHR+alUWsnu1ShQOwt+Q==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "chalk": "^4.1.0",
-            "hash-sum": "^2.0.0",
-            "loader-utils": "^2.0.0"
-          }
         }
       }
     },
@@ -17479,6 +17400,87 @@
         }
       }
     },
+    "vue-loader-v16": {
+      "version": "npm:vue-loader@16.1.2",
+      "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.2.tgz",
+      "integrity": "sha512-8QTxh+Fd+HB6fiL52iEVLKqE9N1JSlMXLR92Ijm6g8PZrwIxckgpqjPDWRP5TWxdiPaHR+alUWsnu1ShQOwt+Q==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "chalk": "^4.1.0",
+        "hash-sum": "^2.0.0",
+        "loader-utils": "^2.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
+          "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true,
+          "optional": true
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true,
+          "optional": true
+        },
+        "loader-utils": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
+          "integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "big.js": "^5.2.2",
+            "emojis-list": "^3.0.0",
+            "json5": "^2.1.2"
+          }
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "optional": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
     "vue-material-design-icons": {
       "version": "4.11.0",
       "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.11.0.tgz",
diff --git a/ui/src/components/executions/VarValue.vue b/ui/src/components/executions/VarValue.vue
index c7d24c3659..1028ba1461 100644
--- a/ui/src/components/executions/VarValue.vue
+++ b/ui/src/components/executions/VarValue.vue
@@ -4,7 +4,10 @@
         target="_blank"
         :href="itemUrl(value)"
     >
-        <download /> {{ $t('download') }}
+        <kicon placement="left">
+            <download /> {{ $t('download') }}
+            <span v-if="humanSize">({{ humanSize }})</span>
+        </kicon>
     </b-link>
     <span v-else v-html="value" />
 </template>
@@ -12,10 +15,18 @@
 <script>
     import {apiRoot} from "../../http";
     import Download from "vue-material-design-icons/Download";
+    import Kicon from "../Kicon"
+    import Utils from "../../utils/utils";
 
     export default {
         components: {
-            Download
+            Download,
+            Kicon
+        },
+        data () {
+            return {
+                humanSize: ""
+            }
         },
         methods: {
             isFile(value) {
@@ -24,6 +35,22 @@
             itemUrl(value) {
                 return `${apiRoot}executions/${this.execution.id}/file?path=${value}`;
             },
+
+        },
+        created() {
+            if (this.isFile(this.value)) {
+                this.$http(
+                    `${apiRoot}executions/${this.execution.id}/file/metas?path=${this.value}`,
+                    {
+                        validateStatus: (status) => {
+                            return status === 200 || status === 404;
+                        }
+                    }
+                )
+                    .then(
+                        r => this.humanSize = Utils.humanFileSize(r.data.size)
+                    )
+            }
         },
         props: {
             value: {
diff --git a/ui/src/utils/utils.js b/ui/src/utils/utils.js
index 5b6489a9ee..1864bfd273 100644
--- a/ui/src/utils/utils.js
+++ b/ui/src/utils/utils.js
@@ -47,4 +47,36 @@ export default class Utils {
 
         })
     }
+
+    /**
+     * Format bytes as human-readable text.
+     *
+     * @param bytes Number of bytes.
+     * @param si True to use metric (SI) units, aka powers of 1000. False to use
+     *           binary (IEC), aka powers of 1024.
+     * @param dp Number of decimal places to display.
+     *
+     * @return Formatted string.
+     */
+    static humanFileSize (bytes, si=false, dp=1) {
+    const thresh = si ? 1000 : 1024;
+
+    if (Math.abs(bytes) < thresh) {
+      return bytes + " B";
+    }
+
+    const units = si
+      ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
+      : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
+    let u = -1;
+    const r = 10**dp;
+
+    do {
+      bytes /= thresh;
+      ++u;
+    } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
+
+
+    return bytes.toFixed(dp) + " " + units[u];
+  }
 }
diff --git a/webserver/src/main/java/org/kestra/webserver/controllers/ExecutionController.java b/webserver/src/main/java/org/kestra/webserver/controllers/ExecutionController.java
index 9af3d0a09f..bfad835a1f 100644
--- a/webserver/src/main/java/org/kestra/webserver/controllers/ExecutionController.java
+++ b/webserver/src/main/java/org/kestra/webserver/controllers/ExecutionController.java
@@ -21,6 +21,7 @@
 import org.kestra.core.models.executions.Execution;
 import org.kestra.core.models.executions.ExecutionKilled;
 import org.kestra.core.models.executions.TaskRun;
+import org.kestra.core.models.storage.FileMetas;
 import org.kestra.core.models.flows.Flow;
 import org.kestra.core.models.flows.State;
 import org.kestra.core.models.hierarchies.FlowGraph;
@@ -39,19 +40,16 @@
 import org.kestra.webserver.utils.PageableUtils;
 import org.reactivestreams.Publisher;
 
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import javax.inject.Named;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
+import java.util.*;
 import java.util.concurrent.atomic.AtomicReference;
-import javax.annotation.Nullable;
-import javax.inject.Inject;
-import javax.inject.Named;
 
 import static org.kestra.core.utils.Rethrow.throwFunction;
 
@@ -307,38 +305,58 @@ public Execution trigger(
         return current;
     }
 
-    /**
-     * Download file binary from uri parameter
-     *
-     * @param path The file URI to return
-     * @return data binary content
-     */
-    @ExecuteOn(TaskExecutors.IO)
-    @Get(uri = "executions/{executionId}/file", produces = MediaType.APPLICATION_OCTET_STREAM)
-    public StreamedFile file(
-        String executionId,
-        @QueryValue(value = "path") URI path
-    ) throws IOException, URISyntaxException {
+    protected void validateFile(String executionId, URI path) {
         Optional<Execution> execution = executionRepository.findById(executionId);
         if (execution.isEmpty()) {
-            return null;
+            throw new NoSuchElementException("Unable to find execution id '" + executionId + "'");
         }
 
         Optional<Flow> flow = flowRepository.findById(execution.get().getNamespace(), execution.get().getFlowId());
         if (flow.isEmpty()) {
-            return null;
+            throw new NoSuchElementException("Unable to find flow id '" + executionId + "'");
         }
 
         String prefix = storageInterface.executionPrefix(flow.get(), execution.get());
         if (!path.getPath().substring(1).startsWith(prefix)) {
             throw new IllegalArgumentException("Invalid prefix path");
         }
+    }
+    /**
+     * Download file binary from uri parameter
+     *
+     * @param path The file URI to return
+     * @return data binary content
+     */
+    @ExecuteOn(TaskExecutors.IO)
+    @Get(uri = "executions/{executionId}/file", produces = MediaType.APPLICATION_OCTET_STREAM)
+    public StreamedFile file(
+        String executionId,
+        @QueryValue(value = "path") URI path
+    ) throws IOException, URISyntaxException {
+        this.validateFile(executionId, path);
 
         InputStream fileHandler = storageInterface.get(path);
         return new StreamedFile(fileHandler, MediaType.APPLICATION_OCTET_STREAM_TYPE)
             .attach(FilenameUtils.getName(path.toString()));
     }
 
+    /**
+     * Get file meta information from given path
+     *
+     * @param path The file URI to gather metas values
+     * @return meta data about given file
+     */
+    @ExecuteOn(TaskExecutors.IO)
+    @Get(uri = "executions/{executionId}/file/metas", produces = MediaType.TEXT_JSON)
+    public FileMetas filesize(
+        String executionId,
+        @QueryValue(value = "path") URI path
+    ) throws IOException {
+        this.validateFile(executionId, path);
+
+        return FileMetas.builder().size(storageInterface.size(path)).build();
+    }
+
     /**
      * Create a new execution from an old one and start it from a specified ("reference") task id
      *


Test patch:
diff --git a/storage-local/src/test/java/org/kestra/storage/local/LocalStorageTest.java b/storage-local/src/test/java/org/kestra/storage/local/LocalStorageTest.java
index ca78211012..903b3007d4 100644
--- a/storage-local/src/test/java/org/kestra/storage/local/LocalStorageTest.java
+++ b/storage-local/src/test/java/org/kestra/storage/local/LocalStorageTest.java
@@ -63,6 +63,8 @@ void put() throws Exception {
             is(CharStreams.toString(new InputStreamReader(new FileInputStream(Objects.requireNonNull(resource).getFile()))))
         );
 
+        assertThat(storageInterface.size(new URI("/file/storage/put.yml")), is(77L));
+
         boolean delete = storageInterface.delete(put);
         assertThat(delete, is(true));
 
diff --git a/webserver/src/test/java/org/kestra/webserver/controllers/ExecutionControllerTest.java b/webserver/src/test/java/org/kestra/webserver/controllers/ExecutionControllerTest.java
index 113ca006b0..ba8b5f1b7d 100644
--- a/webserver/src/test/java/org/kestra/webserver/controllers/ExecutionControllerTest.java
+++ b/webserver/src/test/java/org/kestra/webserver/controllers/ExecutionControllerTest.java
@@ -18,6 +18,7 @@
 import org.kestra.core.models.executions.TaskRun;
 import org.kestra.core.models.flows.Flow;
 import org.kestra.core.models.flows.State;
+import org.kestra.core.models.storage.FileMetas;
 import org.kestra.core.models.triggers.types.Webhook;
 import org.kestra.core.queues.QueueFactoryInterface;
 import org.kestra.core.queues.QueueInterface;
@@ -27,13 +28,13 @@
 import org.kestra.core.utils.IdUtils;
 import org.kestra.webserver.responses.PagedResults;
 
+import javax.inject.Inject;
+import javax.inject.Named;
 import java.io.File;
 import java.time.Duration;
 import java.util.*;
 import java.util.concurrent.TimeoutException;
 import java.util.stream.IntStream;
-import javax.inject.Inject;
-import javax.inject.Named;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.*;
@@ -384,6 +385,13 @@ void downloadFile() throws TimeoutException {
 
         assertThat(file, containsString("micronaut:"));
 
+        FileMetas metas = client.retrieve(
+            HttpRequest.GET("/api/v1/executions/" + execution.getId() + "/file/metas?path=" + path),
+            FileMetas.class
+        ).blockingFirst();
+
+        assertThat(metas.getSize(), equalTo(288L));
+
         HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () -> client.toBlocking().retrieve(
             HttpRequest.GET("/api/v1/executions/" + execution.getId() + "/file?path=" + path.replace(execution.getId(),
                 IdUtils.create()