Repository: kestra-io/kestra
Issue: Logs : add a full screen, copy & download button 

![image](https://user-images.githubusercontent.com/2064609/139411461-052b79c4-2619-4d44-8f5b-d3e1f8d68155.png)


Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/ui/src/App.vue b/ui/src/App.vue
index 6aa5aeeb21..e115c0159f 100644
--- a/ui/src/App.vue
+++ b/ui/src/App.vue
@@ -11,6 +11,10 @@
                     <errors :code="error" />
                 </template>
             </div>
+            <b-modal hide-header hide-footer scrollable id="log-fullscreen-modal" modal-class="modal-fullscreen">
+                <!--prevent load dom in memory most of the time with v-if-->
+                <logs v-if="fullscreen" />
+            </b-modal>
         </div>
         <div id="theme-loaded" />
     </div>
@@ -23,6 +27,7 @@
     import NprogressContainer from "vue-nprogress/src/NprogressContainer";
     import Errors from "./components/errors/Errors";
     import {mapState} from "vuex";
+    import Logs from "./components/executions/Logs";
 
     export default {
         name: "App",
@@ -31,7 +36,8 @@
             TopNavBar,
             CustomToast,
             NprogressContainer,
-            Errors
+            Errors,
+            Logs
         },
         data() {
             return {
@@ -40,7 +46,8 @@
             };
         },
         computed: {
-            ...mapState("core", ["message", "error", "themes", "theme"])
+            ...mapState("core", ["message", "error", "themes", "theme"]),
+            ...mapState("log", ["fullscreen"])
         },
         created() {
             if (this.created === false) {
@@ -170,5 +177,21 @@
 
 
 <style lang="scss">
-    // @import "styles/theme-light";
+.modal-fullscreen .modal {
+    padding: 0 !important;
+}
+.modal-fullscreen .modal-dialog {
+    max-width: 100%;
+    height: 100%;
+    margin: 0;
+}
+.modal-fullscreen .modal-content {
+    border: 0;
+    border-radius: 0;
+    min-height: 100%;
+    height: auto;
+}
+.modal-fullscreen .modal-dialog {
+    max-height: 100%;
+}
 </style>
diff --git a/ui/src/components/executions/ChangeStatus.vue b/ui/src/components/executions/ChangeStatus.vue
index 04f812268c..cde5b8db84 100644
--- a/ui/src/components/executions/ChangeStatus.vue
+++ b/ui/src/components/executions/ChangeStatus.vue
@@ -1,6 +1,6 @@
 <template>
     <b-button
-        @click="$bvModal.show(uuid)"
+        @click="onChangeStatus(uuid)"
         :disabled="!enabled"
     >
         <kicon :tooltip="$t('change status')">
@@ -91,6 +91,10 @@
         },
 
         methods: {
+            onChangeStatus(uuid) {
+                this.$store.commit("log/setFullscreen", false)
+                this.$bvModal.show(uuid)
+            },
             changeStatus(closeCallback) {
                 closeCallback()
 
diff --git a/ui/src/components/executions/Logs.vue b/ui/src/components/executions/Logs.vue
index eb33dab2c9..da859bd068 100644
--- a/ui/src/components/executions/Logs.vue
+++ b/ui/src/components/executions/Logs.vue
@@ -1,5 +1,30 @@
 <template>
     <div>
+        <b-row>
+            <b-col>
+                <div class="float-right">
+                    <b-button @click="copy">
+                        <kicon :tooltip="$t('copy logs')">
+                            <content-copy />
+                        </kicon>
+                    </b-button>
+                    <a :download="downloadName" :href="downloadContent">
+                        <b-button class="ml-2">
+                            <kicon :tooltip="$t('download logs')">
+                                <download />
+                            </kicon>
+                        </b-button>
+                    </a>
+                    <b-button class="ml-2" @click="toggleLogFullscreen">
+                        <kicon :tooltip="$t('toggle fullscreen')">
+                            <full-screen-exit v-if="fullscreen" />
+                            <full-screen v-else />
+                        </kicon>
+                    </b-button>
+                </div>
+            </b-col>
+        </b-row>
+
         <log-filters @input="onChange" :filter="filterTerm" :level="level" />
         <log-list :level="level" :exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']" :filter="filterTerm" />
     </div>
@@ -8,22 +33,57 @@
     import LogList from "../logs/LogList";
     import LogFilters from "../logs/LogFilters";
     import {mapState} from "vuex";
-
+    import FullScreenExit from "vue-material-design-icons/FullscreenExit.vue";
+    import FullScreen from "vue-material-design-icons/Fullscreen.vue";
+    import Download from "vue-material-design-icons/Download.vue";
+    import ContentCopy from "vue-material-design-icons/ContentCopy.vue";
+    import Kicon from "../Kicon";
+    
     export default {
-        components: {LogList, LogFilters},
+        components: {
+            LogList, 
+            LogFilters, 
+            Kicon, 
+            FullScreenExit, 
+            FullScreen,
+            Download,
+            ContentCopy,
+        },
         computed: {
             ...mapState("execution", ["execution", "taskRun", "logs"]),
+            ...mapState("log", ["fullscreen"]),
             filterTerm() {
                 return (this.$route.query.q || "").toLowerCase();
             },
             level() {
                 return this.$route.query.level || "INFO";
             },
+            downloadContent() {
+                return "data:text/plain;base64," + btoa(this.contentAsText)
+            },
+            contentAsText() {
+                return this.logs.map(l => `${l.timestamp} | ${l.level} | ${l.message}`).join("\n")
+            },
+            downloadName() {
+                return `kestra-execution-${this.$moment().format("YYYYMMDDHHmmss")}-${this.execution.id}.log`
+            }
         },
         methods: {
             onChange(event) {
                 this.$router.push({query: {...this.$route.query, q: event.filter, level: event.level, page: 1}});
-            }
+            },
+            toggleLogFullscreen() {
+                this.$store.commit("log/setFullscreen", !this.fullscreen)
+                if (this.fullscreen) {
+                    this.$bvModal.show("log-fullscreen-modal")
+                } else {
+                    this.$bvModal.hide("log-fullscreen-modal")
+                }
+            },
+            copy () {
+                navigator.clipboard.writeText(this.contentAsText);
+                this.$toast().success(this.$t("copied"));
+            },
         }
     };
 </script>
diff --git a/ui/src/components/executions/Restart.vue b/ui/src/components/executions/Restart.vue
index b25439f59f..616cfe2694 100644
--- a/ui/src/components/executions/Restart.vue
+++ b/ui/src/components/executions/Restart.vue
@@ -1,6 +1,6 @@
 <template>
     <b-button
-        @click="$bvModal.show(uuid)"
+        @click="onRestart(uuid)"
         v-if="isReplay || enabled"
         :disabled="!enabled"
         :class="!isReplay ? 'btn-info restart mr-1' : ''"
@@ -71,6 +71,10 @@
             }
         },
         methods: {
+            onRestart(uuid) {
+                this.$store.commit("log/setFullscreen", false)
+                this.$bvModal.show(uuid)
+            },
             loadRevision() {
                 this.revisionsSelected = this.execution.flowRevision
                 this.$store
diff --git a/ui/src/components/logs/LogList.vue b/ui/src/components/logs/LogList.vue
index 49900de8a3..8345be6f2a 100644
--- a/ui/src/components/logs/LogList.vue
+++ b/ui/src/components/logs/LogList.vue
@@ -213,6 +213,7 @@
         },
         computed: {
             ...mapState("execution", ["execution", "taskRun", "task", "logs"]),
+            ...mapState("log", ["fullscreen"]),
         },
         methods: {
             forwardEvent(type, event) {
@@ -249,7 +250,7 @@
                     params.taskId = this.taskId;
                 }
 
-                if (this.execution && this.execution.state.current === State.RUNNING) {
+                if (this.execution && this.execution.state.current === State.RUNNING && !this.fullscreen) {
                     this.$store
                         .dispatch("execution/followLogs", {
                             id: this.$route.params.id,
@@ -269,11 +270,13 @@
                             }
                         });
                 } else {
-                    this.$store.dispatch("execution/loadLogs", {
-                        executionId: this.$route.params.id,
-                        params: params,
-                    });
-                    this.closeSSE();
+                    if (!this.fullscreen){
+                        this.$store.dispatch("execution/loadLogs", {
+                            executionId: this.$route.params.id,
+                            params: params,
+                        });
+                        this.closeSSE();
+                    }
                 }
             },
             closeSSE() {
diff --git a/ui/src/stores/logs.js b/ui/src/stores/logs.js
index 94b746c815..8e5d995adf 100644
--- a/ui/src/stores/logs.js
+++ b/ui/src/stores/logs.js
@@ -4,7 +4,8 @@ export default {
     state: {
         logs: undefined,
         total: 0,
-        level: "INFO"
+        level: "INFO",
+        fullscreen: false
     },
     actions: {
         findLogs({commit}, options) {
@@ -24,5 +25,8 @@ export default {
         setLevel(state, level) {
             state.level = level
         },
+        setFullscreen(state, value) {
+            state.fullscreen = value
+        }
     },
 }
diff --git a/ui/src/translations.json b/ui/src/translations.json
index 50330efa05..6a53b58aff 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -10,7 +10,7 @@
     "theme": "Theme",
     "flows": "Flows",
     "flow": "Flow",
-    "invalid source" : "Invalid source code",
+    "invalid source": "Invalid source code",
     "update": "Update",
     "update ok": "is updated",
     "delete ok": "is deleted",
@@ -216,7 +216,11 @@
         "title": "Page not found",
         "content": "The requested URL was not found on this server. <span class=\"text-muted\">That’s all we know.</span>"
       }
-    }
+    },
+    "copy logs": "Copy logs",
+    "download logs": "Download logs",
+    "toggle fullscreen": "Toggle fullscreen",
+    "copied": "Copied"
   },
   "fr": {
     "id": "Identifiant",
@@ -229,7 +233,7 @@
     "theme": "Thème",
     "flows": "Flows",
     "flow": "Flow",
-    "invalid source" : "Code source invalide",
+    "invalid source": "Code source invalide",
     "update": "Mettre à jour",
     "update ok": "est mis à jour",
     "delete ok": "est effacé",
@@ -436,6 +440,10 @@
         "title": "Page introuvable",
         "content": "L'URL demandé est introuvable sur ce serveur. <span class=\"text-muted\">C'est tout ce que nous savons.</span>"
       }
-    }
+    },
+    "copy logs": "Copier les logs",
+    "download logs": "Télécharger les logs",
+    "toggle fullscreen": "Permuter le plein écran",
+    "copied": "Copié"
   }
 }


Test patch: