Repository: kestra-io/kestra
Issue: Add a custom UI color and label helping to distinguish between development and production environments
Labels: enhancement

### Feature description

As shown in [this great post](https://www.morling.dev/blog/oh_this_is_prod/), it's important to be able to distinguish between development and production environments easily. However, often it's easy to accidentally confuse production and development UIs if they look the same way.

## Proposal
Add an option in the UI settings to set a custom color and label so that users can OPTIONALLY customize Kestra UI based on their environment.

![image](https://github.com/kestra-io/kestra/assets/86264395/e1c45396-7daa-4d1b-81dd-63eb64e6ad30)

![image](https://github.com/kestra-io/kestra/assets/86264395/568d418a-b93a-44d5-b3e0-314baa5f9221)




Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/ui/src/components/layout/Environment.vue b/ui/src/components/layout/Environment.vue
new file mode 100644
index 0000000000..25acca9d23
--- /dev/null
+++ b/ui/src/components/layout/Environment.vue
@@ -0,0 +1,43 @@
+<template>
+    <div v-if="name" id="environment">
+        <strong>{{ name }}</strong>
+    </div>
+</template>
+
+<script>
+    import {mapGetters} from "vuex";
+    import {cssVariable} from "../../utils/global";
+
+    export default {
+        computed: {
+            ...mapGetters("layout", ["envName", "envColor"]),
+            ...mapGetters("misc", ["configs"]),
+            name() {
+                return this.envName || this.configs?.environment?.name;
+            },
+            color() {
+                if (this.envColor) {
+                    return this.envColor;
+                }
+
+                if (this.configs?.environment?.color) {
+                    return this.configs.environment.color;
+                }
+
+                return cssVariable("--bs-info");
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+#environment {
+    margin-bottom: 0.5em;
+    background-color: v-bind('color');
+    text-align: center;
+
+    strong {
+        color: var(--bs-body-bg);
+    }
+}
+</style>
\ No newline at end of file
diff --git a/ui/src/components/settings/Settings.vue b/ui/src/components/settings/Settings.vue
index f7f450a2e3..ef23b3247b 100644
--- a/ui/src/components/settings/Settings.vue
+++ b/ui/src/components/settings/Settings.vue
@@ -108,6 +108,23 @@
                     />
                 </el-select>
             </el-form-item>
+
+            <el-form-item :label="$t('environment name setting')">
+                <el-input
+                    v-model="envName"
+                    @change="onEnvNameChange"
+                    :placeholder="$t('name')"
+                    clearable
+                />
+            </el-form-item>
+
+            <el-form-item :label="$t('environment color setting')">
+                <el-color-picker
+                    v-model="envColor"
+                    @change="onEnvColorChange"
+                    show-alpha
+                />
+            </el-form-item>
         </el-form>
     </div>
 </template>
@@ -121,7 +138,7 @@
     import NamespaceSelect from "../../components/namespace/NamespaceSelect.vue";
     import LogLevelSelector from "../../components/logs/LogLevelSelector.vue";
     import Utils from "../../utils/utils";
-    import {mapGetters, mapState} from "vuex";
+    import {mapGetters, mapState, useStore} from "vuex";
     import permission from "../../models/permission";
     import action from "../../models/action";
     import {logDisplayTypes} from "../../utils/constants";
@@ -156,11 +173,14 @@
                 logDisplay: undefined,
                 editorFontSize: undefined,
                 editorFontFamily: undefined,
-                now: this.$moment()
+                now: this.$moment(),
+                envName: undefined,
+                envColor: undefined
             };
         },
         created() {
             const darkTheme = document.getElementsByTagName("html")[0].className.indexOf("dark") >= 0;
+            const store = useStore();
 
             this.defaultNamespace = localStorage.getItem("defaultNamespace") || "";
             this.defaultLogLevel = localStorage.getItem("defaultLogLevel") || "INFO";
@@ -174,6 +194,8 @@
             this.logDisplay = localStorage.getItem("logDisplay") || logDisplayTypes.DEFAULT;
             this.editorFontSize = localStorage.getItem("editorFontSize") || 12;
             this.editorFontFamily = localStorage.getItem("editorFontFamily") || "'Source Code Pro', monospace";
+            this.envName = store.getters["layout/envName"] || this.configs?.environment?.name;
+            this.envColor = store.getters["layout/envColor"] || this.configs?.environment?.color;
         },
         methods: {
             onNamespaceSelect(value) {
@@ -255,12 +277,28 @@
             onFontFamily(value) {
                 localStorage.setItem("editorFontFamily", value);
                 this.editorFontFamily = value;
+                this.$toast().saved();
+            },
+            onEnvNameChange(value) {
+                if (value && value !== this.configs?.environment?.name) {
+                    this.$store.commit("layout/setEnvName", value);
+                }
+
+                this.$toast().saved();
+            },
+            onEnvColorChange(value) {
+                console.log(value, this.configs?.environment?.color)
+                if (value && value !== this.configs?.environment?.color) {
+                    this.$store.commit("layout/setEnvColor", value);
+                }
+
                 this.$toast().saved();
             }
         },
         computed: {
             ...mapGetters("core", ["guidedProperties"]),
             ...mapState("auth", ["user"]),
+            ...mapGetters("misc", ["configs"]),
             routeInfo() {
                 return {
                     title: this.$t("settings")
diff --git a/ui/src/override/components/LeftMenu.vue b/ui/src/override/components/LeftMenu.vue
index f98fb6c5d5..f1711ed831 100644
--- a/ui/src/override/components/LeftMenu.vue
+++ b/ui/src/override/components/LeftMenu.vue
@@ -14,6 +14,7 @@
                     <span class="img" />
                 </router-link>
             </div>
+            <Environment />
         </template>
 
         <template #footer>
@@ -29,6 +30,7 @@
 
 <script>
     import {SidebarMenu} from "vue-sidebar-menu";
+    import Environment from "../../components/layout/Environment.vue";
     import ChevronLeft from "vue-material-design-icons/ChevronLeft.vue";
     import ChevronRight from "vue-material-design-icons/ChevronRight.vue";
     import FileTreeOutline from "vue-material-design-icons/FileTreeOutline.vue";
@@ -53,6 +55,7 @@
             ChevronLeft,
             ChevronRight,
             SidebarMenu,
+            Environment,
         },
         emits: ["menu-collapse"],
         methods: {
@@ -257,7 +260,7 @@
         .logo {
             overflow: hidden;
             padding: 35px 0;
-            height: 133px;
+            height: 113px;
             position: relative;
 
             a {
diff --git a/ui/src/stores/layout.js b/ui/src/stores/layout.js
index f1b845d761..537c808d54 100644
--- a/ui/src/stores/layout.js
+++ b/ui/src/stores/layout.js
@@ -1,13 +1,36 @@
 export default {
     namespaced: true,
     state: {
-        topNavbar: undefined
+        topNavbar: undefined,
+        envName: undefined,
+        envColor: undefined
     },
     actions: {},
     mutations: {
         setTopNavbar(state, value) {
             state.topNavbar = value
+        },
+        setEnvName(state, value) {
+            localStorage.setItem("envName", value);
+            state.envName = value;
+        },
+        setEnvColor(state, value) {
+            localStorage.setItem("envColor", value);
+            state.envColor = value;
         }
     },
-    getters: {}
+    getters: {
+        envName(state) {
+            if (!state.envName) {
+                state.envName = localStorage.getItem("envName");
+            }
+            return state.envName;
+        },
+        envColor(state) {
+            if (!state.envColor) {
+                state.envColor = localStorage.getItem("envColor");
+            }
+            return state.envColor;
+        }
+    }
 }
\ No newline at end of file
diff --git a/ui/src/translations.json b/ui/src/translations.json
index b69f2acead..9d27bab8f3 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -459,6 +459,8 @@
     "expand all": "Expand all",
     "collapse all": "Collapse all",
     "log expand setting": "Log default display",
+    "environment name setting": "Environment name",
+    "environment color setting": "Environment color",
     "slack support": "Ask help on our Slack",
     "error detected": "Error detected",
     "cannot swap tasks": "Can't swap the tasks",
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
index b3f14114d7..46807317bd 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
@@ -6,6 +6,7 @@
 import io.kestra.core.services.CollectorService;
 import io.kestra.core.services.InstanceService;
 import io.kestra.core.utils.VersionProvider;
+import io.micronaut.core.annotation.Nullable;
 import io.micronaut.http.HttpResponse;
 import io.micronaut.http.annotation.Controller;
 import io.micronaut.http.annotation.Get;
@@ -36,6 +37,14 @@ public class MiscController {
     @io.micronaut.context.annotation.Value("${kestra.anonymous-usage-report.enabled}")
     protected Boolean isAnonymousUsageEnabled;
 
+    @io.micronaut.context.annotation.Value("${kestra.environment.name}")
+    @Nullable
+    protected String environmentName;
+
+    @io.micronaut.context.annotation.Value("${kestra.environment.color}")
+    @Nullable
+    protected String environmentColor;
+
     @Get("/ping")
     @Hidden
     public HttpResponse<?> ping() {
@@ -46,14 +55,24 @@ public HttpResponse<?> ping() {
     @ExecuteOn(TaskExecutors.IO)
     @Operation(tags = {"Misc"}, summary = "Get current configurations")
     public Configuration configuration() {
-        return Configuration
+        Configuration.ConfigurationBuilder builder = Configuration
             .builder()
             .uuid(instanceService.fetch())
             .version(versionProvider.getVersion())
             .isTaskRunEnabled(executionRepository.isTaskRunEnabled())
             .isAnonymousUsageEnabled(this.isAnonymousUsageEnabled)
-            .isWorkerInstanceEnabled(false)
-            .build();
+            .isWorkerInstanceEnabled(false);
+
+        if (this.environmentName != null || this.environmentColor != null) {
+            builder.environment(
+                Environment.builder()
+                    .name(this.environmentName)
+                    .color(this.environmentColor)
+                    .build()
+            );
+        }
+
+        return builder.build();
     }
 
     @Get("/api/v1/usages")
@@ -78,5 +97,14 @@ public static class Configuration {
 
         @JsonInclude
         Boolean isWorkerInstanceEnabled;
+
+        Environment environment;
+    }
+
+    @Value
+    @Builder(toBuilder = true)
+    public static class Environment {
+        String name;
+        String color;
     }
 }


Test patch: